diff --git a/.gitignore b/.gitignore index 2e993bb..fbfcb98 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ coverage/ __pycache__/ opencode-plugin-template/ opencode-docs-*/ +package-lock.json +package-lock.json diff --git a/.mise/tasks/lint:fix b/.mise/tasks/lint-fix old mode 100755 new mode 100644 similarity index 76% rename from .mise/tasks/lint:fix rename to .mise/tasks/lint-fix index bcdc53b..4768a4a --- a/.mise/tasks/lint:fix +++ b/.mise/tasks/lint-fix @@ -1,3 +1,3 @@ #!/usr/bin/env bash #MISE description="Run Biome lint with auto-fix" -biome lint --write . \ No newline at end of file +biome lint --write . diff --git a/src/index.ts b/src/index.ts index 04fd7a5..ecce81d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,11 +23,17 @@ interface ParsedCommand { } function parseFrontmatter(content: string): { frontmatter: CommandFrontmatter; body: string } { + // EN: Strip UTF-8 BOM, normalize CRLF → LF (cross-platform .md files) + // RU: Удаление BOM, нормализация CRLF → LF (кроссплатформенность) + const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); + const match = normalized.match(frontmatterRegex); if (!match) { - return { frontmatter: {}, body: content.trim() }; + // EN: No frontmatter — use first non-empty line as description fallback + // RU: Нет frontmatter — первая непустая строка как описание + const firstLine = normalized.split('\n').find((l) => l.trim()) ?? ''; + return { frontmatter: { description: firstLine.trim() }, body: normalized.trim() }; } const [, yamlContent, body] = match; @@ -103,7 +109,9 @@ async function loadCommands(): Promise { frontmatter, template: body, }); - } catch {} + } catch { + // Skip malformed command files + } } return commands; @@ -270,7 +278,9 @@ export const opencodeConfigSync: Plugin = async (ctx) => { // Delay startup sync slightly to ensure TUI is connected setTimeout(() => { - void service.startupSync(); + service.startupSync().catch(() => { + // Errors are already logged internally by startupSync + }); }, 1000); return { diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 90b75b4..4c798dd 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -1,3 +1,4 @@ +import type { Dirent } from 'node:fs'; import { promises as fs } from 'node:fs'; import path from 'node:path'; @@ -110,6 +111,90 @@ export async function syncLocalToRepo( await writeExtraPathManifest(plan, plan.extraSecrets); } +interface FileEntry { + relativePath: string; + mtimeMs: number; + size: number; + isDirectory: boolean; +} + +// EN: Recursively walk a directory, collecting file entries with mtime + size for diff comparison +// RU: Рекурсивный обход директории с mtime + size для diff-сравнения +async function walkDir(rootPath: string, relativeDir = ''): Promise { + const entries: FileEntry[] = []; + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(rootPath, { withFileTypes: true }); + } catch { + return entries; + } + for (const entry of dirEntries) { + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + entries.push({ relativePath, mtimeMs: 0, size: 0, isDirectory: true }); + const nested = await walkDir(path.join(rootPath, entry.name), relativePath); + entries.push(...nested); + } else if (entry.isFile()) { + try { + const stat = await fs.stat(path.join(rootPath, entry.name)); + entries.push({ relativePath, mtimeMs: stat.mtimeMs, size: stat.size, isDirectory: false }); + } catch { + // skip unreadable files + } + } + } + return entries; +} + +// EN: Diff-based directory copy — only copies new/changed files, removes deleted ones +// EN: Avoids full directory recreation (unlike removePath + copyDirRecursive) +// RU: Diff-based копирование — копирует только новые/изменённые файлы, удаляет пропавшие +// RU: Без полного пересоздания директорий (в отличие от removePath + copyDirRecursive) +async function applyDirDiff(sourceRoot: string, destRoot: string): Promise { + const [sourceFiles, destFiles] = await Promise.all([walkDir(sourceRoot), walkDir(destRoot)]); + + const sourceMap = new Map(); + for (const f of sourceFiles) sourceMap.set(f.relativePath, f); + + const destMap = new Map(); + for (const f of destFiles) destMap.set(f.relativePath, f); + + const actions: Array<() => Promise> = []; + + for (const [relPath, src] of sourceMap) { + const dst = destMap.get(relPath); + if (!dst) { + if (src.isDirectory) { + actions.push(async () => { + await fs.mkdir(path.join(destRoot, relPath), { recursive: true }); + }); + } else { + actions.push(async () => { + await copyFileWithMode(path.join(sourceRoot, relPath), path.join(destRoot, relPath)); + }); + } + } else if ( + !src.isDirectory && + !dst.isDirectory && + (src.mtimeMs !== dst.mtimeMs || src.size !== dst.size) + ) { + actions.push(async () => { + await copyFileWithMode(path.join(sourceRoot, relPath), path.join(destRoot, relPath)); + }); + } + } + + for (const [relPath] of destMap) { + if (!sourceMap.has(relPath)) { + actions.push(async () => { + await removePath(path.join(destRoot, relPath)); + }); + } + } + + await Promise.all(actions.map((a) => a())); +} + async function copyItem( sourcePath: string, destinationPath: string, @@ -137,8 +222,8 @@ async function copyItem( return; } - await removePath(destinationPath); - await copyDirRecursive(sourcePath, destinationPath); + await fs.mkdir(destinationPath, { recursive: true }); + await applyDirDiff(sourcePath, destinationPath); } async function copyConfigForRepo( diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 20df918..9638377 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -58,7 +58,9 @@ export async function generateCommitMessage( if (sessionId) { try { await ctx.client.session.delete({ path: { id: sessionId } }); - } catch {} + } catch { + // Session deletion is best-effort cleanup + } } } } diff --git a/src/sync/config.ts b/src/sync/config.ts index df2f6a5..72728be 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -61,6 +61,7 @@ export interface SyncConfig { includeModelFavorites?: boolean; includeOpencodeSkills?: boolean; includeAgentsDir?: boolean; + includeProjects?: boolean; secretsBackend?: SecretsBackendConfig; extraSecretPaths?: string[]; extraConfigPaths?: string[]; @@ -75,6 +76,7 @@ export interface NormalizedSyncConfig extends SyncConfig { includeModelFavorites: boolean; includeOpencodeSkills: boolean; includeAgentsDir: boolean; + includeProjects: boolean; secretsBackend?: SecretsBackendConfig; extraSecretPaths: string[]; extraConfigPaths: string[]; @@ -87,6 +89,7 @@ export interface SyncState { lastSecretsHash?: string; lastSessionPull?: string; lastSessionPush?: string; + lastHead?: string; } export async function pathExists(filePath: string): Promise { @@ -175,6 +178,7 @@ export function normalizeSyncConfig(config: SyncConfig): NormalizedSyncConfig { const includeModelFavorites = config.includeModelFavorites !== false; const includeOpencodeSkills = config.includeOpencodeSkills !== false; const includeAgentsDir = config.includeAgentsDir !== false; + const includeProjects = Boolean(config.includeProjects); return { includeSecrets, includeMcpSecrets: includeSecrets ? Boolean(config.includeMcpSecrets) : false, @@ -184,6 +188,7 @@ export function normalizeSyncConfig(config: SyncConfig): NormalizedSyncConfig { includeModelFavorites, includeOpencodeSkills, includeAgentsDir, + includeProjects, secretsBackend: normalizeSecretsBackend(config.secretsBackend), extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [], extraConfigPaths: Array.isArray(config.extraConfigPaths) ? config.extraConfigPaths : [], diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 57b60d8..a3f04f3 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -16,21 +16,19 @@ describe('resolveXdgPaths', () => { it('resolves windows defaults', () => { const env = { USERPROFILE: 'C:\\Users\\Test', - APPDATA: 'C:\\Users\\Test\\AppData\\Roaming', - LOCALAPPDATA: 'C:\\Users\\Test\\AppData\\Local', } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'win32'); - expect(paths.configDir).toBe('C:\\Users\\Test\\AppData\\Roaming'); - expect(paths.dataDir).toBe('C:\\Users\\Test\\AppData\\Local'); + expect(paths.configDir).toBe('C:\\Users\\Test\\.config'); + expect(paths.dataDir).toBe('C:\\Users\\Test\\.local\\share'); }); }); describe('resolveSyncLocations', () => { - it('respects opencode_config_dir', () => { + it('respects OPENCODE_CONFIG_DIR', () => { const env = { HOME: '/home/test', - opencode_config_dir: '/custom/opencode', + OPENCODE_CONFIG_DIR: '/custom/opencode', } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); @@ -235,7 +233,7 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist.length).toBe(1); }); - it('includes sqlite and legacy session paths when includeSessions is true', () => { + it('excludes session paths from plan when sessions use syncSessions merge', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); const config: SyncConfig = { @@ -245,20 +243,10 @@ describe('buildSyncPlan', () => { }; const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); - const expectedSessionPaths = [ - '/.local/share/opencode/opencode.db', - '/.local/share/opencode/storage/session', - '/.local/share/opencode/storage/message', - '/.local/share/opencode/storage/part', - '/.local/share/opencode/storage/session_diff', - ]; - - for (const suffix of expectedSessionPaths) { - const sessionItem = plan.items.find((item) => item.localPath.endsWith(suffix)); - expect(sessionItem).toBeTruthy(); - expect(sessionItem?.isSecret).toBe(true); - expect(sessionItem?.preserveWhenMissing).toBe(true); - } + const sessionDbItem = plan.items.find((item) => item.localPath.endsWith('opencode.db')); + expect(sessionDbItem).toBeUndefined(); + const sessionDirItem = plan.items.find((item) => item.localPath.includes('storage/session')); + expect(sessionDirItem).toBeUndefined(); }); it('excludes git session paths when using turso session backend', () => { diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 54bd147..2640eba 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -52,13 +52,19 @@ const DEFAULT_SYNC_CONFIG_NAME = 'opencode-synced.jsonc'; const DEFAULT_OVERRIDES_NAME = 'opencode-synced.overrides.jsonc'; const DEFAULT_STATE_NAME = 'sync-state.json'; -const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin']; -const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff']; -const SESSION_DB_FILE = 'opencode.db'; +const CONFIG_DIRS = ['agent', 'command', 'commands', 'mode', 'tool', 'themes', 'plugin', 'plugins']; const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl']; const MODEL_FAVORITES_FILE = 'model.json'; const SKILLS_DIR = 'skills'; const HOME_AGENTS_DIR = '.agents'; +const GLOBAL_DAT_FILE = 'opencode.global.dat'; + +// EN: Platform-aware path.join — when testing (platform != runtime), uses posix/win32 module +// RU: path.join с учётом платформы — при тестах (platform != runtime) использует posix/win32 модуль +function platformJoin(platform: NodeJS.Platform, ...parts: string[]): string { + if (platform === 'win32') return path.win32.join(...parts); + return path.posix.join(...parts); +} export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, @@ -86,17 +92,9 @@ export function resolveXdgPaths( }; } - if (platform === 'win32') { - const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming'); - const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA - const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - return { homeDir, configDir, dataDir, stateDir }; - } - - const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config'); - const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share'); - const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state'); + const configDir = env.XDG_CONFIG_HOME ?? platformJoin(platform, homeDir, '.config'); + const dataDir = env.XDG_DATA_HOME ?? platformJoin(platform, homeDir, '.local', 'share'); + const stateDir = env.XDG_STATE_HOME ?? platformJoin(platform, homeDir, '.local', 'state'); return { homeDir, configDir, dataDir, stateDir }; } @@ -106,19 +104,19 @@ export function resolveSyncLocations( platform: NodeJS.Platform = process.platform ): SyncLocations { const xdg = resolveXdgPaths(env, platform); - const customConfigDir = env.opencode_config_dir; + const customConfigDir = env.OPENCODE_CONFIG_DIR; const configRoot = customConfigDir - ? path.resolve(expandHome(customConfigDir, xdg.homeDir)) - : path.join(xdg.configDir, 'opencode'); - const dataRoot = path.join(xdg.dataDir, 'opencode'); + ? platformJoin(platform, expandHome(customConfigDir, xdg.homeDir)) + : platformJoin(platform, xdg.configDir, 'opencode'); + const dataRoot = platformJoin(platform, xdg.dataDir, 'opencode'); return { xdg, configRoot, - syncConfigPath: path.join(configRoot, DEFAULT_SYNC_CONFIG_NAME), - overridesPath: path.join(configRoot, DEFAULT_OVERRIDES_NAME), - statePath: path.join(dataRoot, DEFAULT_STATE_NAME), - defaultRepoDir: path.join(dataRoot, 'opencode-synced', 'repo'), + syncConfigPath: platformJoin(platform, configRoot, DEFAULT_SYNC_CONFIG_NAME), + overridesPath: platformJoin(platform, configRoot, DEFAULT_OVERRIDES_NAME), + statePath: platformJoin(platform, dataRoot, DEFAULT_STATE_NAME), + defaultRepoDir: platformJoin(platform, dataRoot, 'opencode-synced', 'repo'), }; } @@ -126,7 +124,7 @@ export function expandHome(inputPath: string, homeDir: string): string { if (!inputPath) return inputPath; if (!homeDir) return inputPath; if (inputPath === '~') return homeDir; - if (inputPath.startsWith('~/')) return path.join(homeDir, inputPath.slice(2)); + if (inputPath.startsWith('~/')) return `${homeDir}/${inputPath.slice(2)}`; return inputPath; } @@ -135,8 +133,9 @@ export function normalizePath( homeDir: string, platform: NodeJS.Platform = process.platform ): string { - const expanded = expandHome(inputPath, homeDir); - const resolved = path.resolve(expanded); + const pj = (...parts: string[]) => platformJoin(platform, ...parts); + const expanded = expandHome(inputPath, homeDir).replace(/\\/g, '/'); + const resolved = pj(expanded); if (platform === 'win32') { return resolved.toLowerCase(); } @@ -170,33 +169,47 @@ export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocati return locations.defaultRepoDir; } +export function resolveProjectsFilePath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const pj = (...parts: string[]) => platformJoin(platform, ...parts); + if (platform === 'win32') { + const appData = env.APPDATA ?? pj(env.USERPROFILE ?? '', 'AppData', 'Roaming'); + return pj(appData, 'ai.opencode.desktop', GLOBAL_DAT_FILE); + } + const dataDir = env.XDG_DATA_HOME ?? pj(resolveHomeDir(env, platform), '.local', 'share'); + return pj(dataDir, 'opencode', GLOBAL_DAT_FILE); +} + export function buildSyncPlan( config: NormalizedSyncConfig, locations: SyncLocations, repoRoot: string, platform: NodeJS.Platform = process.platform ): SyncPlan { + const pj = (...parts: string[]) => platformJoin(platform, ...parts); const configRoot = locations.configRoot; - const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); - const stateRoot = path.join(locations.xdg.stateDir, 'opencode'); - const repoConfigRoot = path.join(repoRoot, 'config'); - const repoDataRoot = path.join(repoRoot, 'data'); - const repoSecretsRoot = path.join(repoRoot, 'secrets'); - const repoStateRoot = path.join(repoRoot, 'state'); - const repoExtraDir = path.join(repoSecretsRoot, 'extra'); - const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json'); - const repoConfigExtraDir = path.join(repoConfigRoot, 'extra'); - const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); + const dataRoot = pj(locations.xdg.dataDir, 'opencode'); + const stateRoot = pj(locations.xdg.stateDir, 'opencode'); + const repoConfigRoot = pj(repoRoot, 'config'); + const repoDataRoot = pj(repoRoot, 'data'); + const repoSecretsRoot = pj(repoRoot, 'secrets'); + const repoStateRoot = pj(repoRoot, 'state'); + const repoExtraDir = pj(repoSecretsRoot, 'extra'); + const manifestPath = pj(repoSecretsRoot, 'extra-manifest.json'); + const repoConfigExtraDir = pj(repoConfigRoot, 'extra'); + const configManifestPath = pj(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; const usingSecretsBackend = hasSecretsBackend(config); - const authJsonPath = path.join(dataRoot, 'auth.json'); - const mcpAuthJsonPath = path.join(dataRoot, 'mcp-auth.json'); + const authJsonPath = pj(dataRoot, 'auth.json'); + const mcpAuthJsonPath = pj(dataRoot, 'mcp-auth.json'); const addFile = (name: string, isSecret: boolean, isConfigFile: boolean): void => { items.push({ - localPath: path.join(configRoot, name), - repoPath: path.join(repoConfigRoot, name), + localPath: pj(configRoot, name), + repoPath: pj(repoConfigRoot, name), type: 'file', isSecret, isConfigFile, @@ -210,8 +223,8 @@ export function buildSyncPlan( for (const dirName of CONFIG_DIRS) { items.push({ - localPath: path.join(configRoot, dirName), - repoPath: path.join(repoConfigRoot, dirName), + localPath: pj(configRoot, dirName), + repoPath: pj(repoConfigRoot, dirName), type: 'dir', isSecret: false, isConfigFile: false, @@ -220,8 +233,8 @@ export function buildSyncPlan( if (config.includeOpencodeSkills !== false) { items.push({ - localPath: path.join(configRoot, SKILLS_DIR), - repoPath: path.join(repoConfigRoot, SKILLS_DIR), + localPath: pj(configRoot, SKILLS_DIR), + repoPath: pj(repoConfigRoot, SKILLS_DIR), type: 'dir', isSecret: false, isConfigFile: false, @@ -230,8 +243,8 @@ export function buildSyncPlan( if (config.includeAgentsDir !== false) { items.push({ - localPath: path.join(locations.xdg.homeDir, HOME_AGENTS_DIR), - repoPath: path.join(repoConfigRoot, HOME_AGENTS_DIR), + localPath: pj(locations.xdg.homeDir, HOME_AGENTS_DIR), + repoPath: pj(repoConfigRoot, HOME_AGENTS_DIR), type: 'dir', isSecret: false, isConfigFile: false, @@ -240,8 +253,8 @@ export function buildSyncPlan( if (config.includeModelFavorites !== false) { items.push({ - localPath: path.join(stateRoot, MODEL_FAVORITES_FILE), - repoPath: path.join(repoStateRoot, MODEL_FAVORITES_FILE), + localPath: pj(stateRoot, MODEL_FAVORITES_FILE), + repoPath: pj(repoStateRoot, MODEL_FAVORITES_FILE), type: 'file', isSecret: false, isConfigFile: false, @@ -260,7 +273,7 @@ export function buildSyncPlan( }, { localPath: mcpAuthJsonPath, - repoPath: path.join(repoDataRoot, 'mcp-auth.json'), + repoPath: pj(repoDataRoot, 'mcp-auth.json'), type: 'file', isSecret: true, isConfigFile: false, @@ -269,32 +282,15 @@ export function buildSyncPlan( } if (config.includeSessions && !isTursoSessionBackend(config)) { - items.push({ - localPath: path.join(dataRoot, SESSION_DB_FILE), - repoPath: path.join(repoDataRoot, SESSION_DB_FILE), - type: 'file', - isSecret: true, - isConfigFile: false, - preserveWhenMissing: true, - }); - - for (const dirName of SESSION_DIRS) { - items.push({ - localPath: path.join(dataRoot, dirName), - repoPath: path.join(repoDataRoot, dirName), - type: 'dir', - isSecret: true, - isConfigFile: false, - preserveWhenMissing: true, - }); - } + // Session sync uses per-session JSON merge (syncSessions), + // NOT raw DB/storage copy — to avoid overwriting local sessions on pull. } if (config.includePromptStash) { for (const fileName of PROMPT_STASH_FILES) { items.push({ - localPath: path.join(stateRoot, fileName), - repoPath: path.join(repoStateRoot, fileName), + localPath: pj(stateRoot, fileName), + repoPath: pj(repoStateRoot, fileName), type: 'file', isSecret: true, isConfigFile: false, @@ -350,13 +346,14 @@ function buildExtraPathPlan( manifestPath: string, platform: NodeJS.Platform ): ExtraPathPlan { + const pj = (...parts: string[]) => platformJoin(platform, ...parts); const allowlist = (inputPaths ?? []).map((entry) => normalizePath(entry, locations.xdg.homeDir, platform) ); const entries = allowlist.map((sourcePath) => ({ sourcePath, - repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), + repoPath: pj(repoExtraDir, encodeExtraPath(sourcePath)), })); return { diff --git a/src/sync/projects-merge.ts b/src/sync/projects-merge.ts new file mode 100644 index 0000000..df6c117 --- /dev/null +++ b/src/sync/projects-merge.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs'; + +export interface ProjectEntry { + worktree: string; + expanded?: boolean; +} + +export interface ServerData { + list: unknown[]; + projects: { + local: ProjectEntry[]; + }; + lastProject?: { local?: string }; +} + +export interface LastProjectSessionEntry { + directory: string; + id: string; + at: number; +} + +export interface LayoutPageData { + lastProjectSession?: Record; +} + +export interface GlobalData { + [key: string]: string; +} + +function readGlobalData(filePath: string): GlobalData | null { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as GlobalData; + } catch { + return null; + } +} + +function writeGlobalData(filePath: string, data: GlobalData): void { + fs.writeFileSync(filePath, JSON.stringify(data, null, '\t'), 'utf-8'); +} + +function parseServer(raw: string | undefined): ServerData | null { + if (!raw) return null; + try { + return JSON.parse(raw) as ServerData; + } catch { + return null; + } +} + +function parseLayoutPage(raw: string | undefined): LayoutPageData | null { + if (!raw) return null; + try { + return JSON.parse(raw) as LayoutPageData; + } catch { + return null; + } +} + +function unionProjects(local: ProjectEntry[], remote: ProjectEntry[]): ProjectEntry[] { + const map = new Map(); + for (const p of local) map.set(p.worktree.toLowerCase(), p); + for (const p of remote) { + const key = p.worktree.toLowerCase(); + if (!map.has(key)) { + map.set(key, p); + } + } + return [...map.values()]; +} + +function unionLastSession( + local: Record | undefined, + remote: Record | undefined +): Record { + const map = new Map(); + if (local) { + for (const [dir, entry] of Object.entries(local)) { + map.set(dir.toLowerCase(), entry); + } + } + if (remote) { + for (const [dir, entry] of Object.entries(remote)) { + const key = dir.toLowerCase(); + const existing = map.get(key); + if (!existing || entry.at > existing.at) { + map.set(key, entry); + } + } + } + const result: Record = {}; + for (const [_, entry] of map) { + result[entry.directory] = entry; + } + return result; +} + +export function syncGlobalData(localPath: string, remotePath: string): boolean { + const local = readGlobalData(localPath); + const remote = readGlobalData(remotePath); + + if (!local && !remote) return false; + if (!local) { + if (remote) writeGlobalData(localPath, remote); + return true; + } + if (!remote) { + writeGlobalData(remotePath, local); + return true; + } + + const merged: GlobalData = { ...local }; + + for (const key of Object.keys(remote)) { + if (key === 'server') { + const localServer = parseServer(local[key]); + const remoteServer = parseServer(remote[key]); + if (localServer && remoteServer) { + const mergedProjects = unionProjects( + localServer.projects?.local ?? [], + remoteServer.projects?.local ?? [] + ); + merged[key] = JSON.stringify({ + ...localServer, + projects: { local: mergedProjects }, + }); + } else { + merged[key] = local[key] ?? remote[key]; + } + } else if (key === 'layout.page') { + const localLayout = parseLayoutPage(local[key]); + const remoteLayout = parseLayoutPage(remote[key]); + if (localLayout && remoteLayout) { + const mergedSessions = unionLastSession( + localLayout.lastProjectSession, + remoteLayout.lastProjectSession + ); + merged[key] = JSON.stringify({ + ...localLayout, + lastProjectSession: mergedSessions, + }); + } else { + merged[key] = local[key] ?? remote[key]; + } + } else if (!(key in local)) { + merged[key] = remote[key]; + } + } + + writeGlobalData(localPath, merged); + writeGlobalData(remotePath, merged); + return true; +} diff --git a/src/sync/repo.ts b/src/sync/repo.ts index c5ffa9b..235dce6 100644 --- a/src/sync/repo.ts +++ b/src/sync/repo.ts @@ -161,6 +161,15 @@ export async function pushBranch($: Shell, repoDir: string, branch: string): Pro } } +export async function getHeadHash($: Shell, repoDir: string): Promise { + try { + const output = await $`git -C ${repoDir} rev-parse HEAD`.quiet().text(); + return output.trim() || null; + } catch { + return null; + } +} + async function getCurrentBranch($: Shell, repoDir: string): Promise { try { const output = await $`git -C ${repoDir} rev-parse --abbrev-ref HEAD`.quiet().text(); diff --git a/src/sync/service.ts b/src/sync/service.ts index 3604254..156b54b 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; -import { generateCommitMessage } from './commit.js'; + import type { NormalizedSyncConfig } from './config.js'; import { canCommitMcpSecrets, @@ -19,7 +19,13 @@ import { import { SyncCommandError, SyncConfigMissingError } from './errors.js'; import type { SyncLockInfo } from './lock.js'; import { withSyncLock } from './lock.js'; -import { buildSyncPlan, resolveRepoRoot, resolveSyncLocations } from './paths.js'; +import { + buildSyncPlan, + resolveProjectsFilePath, + resolveRepoRoot, + resolveSyncLocations, +} from './paths.js'; +import { syncGlobalData } from './projects-merge.js'; import { commitAll, ensureRepoCloned, @@ -27,6 +33,7 @@ import { fetchAndFastForward, findSyncRepo, getAuthenticatedUser, + getHeadHash, getRepoStatus, hasLocalChanges, isRepoCloned, @@ -43,6 +50,8 @@ import { resolveSecretsBackendConfig, type SecretsBackend, } from './secrets-backend.js'; +import { syncSessions } from './session-merge.js'; +import { createNodeShell } from './shell.js'; import { createTursoSessionBackend, isRetryableTursoError, @@ -51,6 +60,7 @@ import { import { createLogger, extractTextFromResponse, + notify, resolveSmallModel, showToast, unwrapData, @@ -114,6 +124,10 @@ export interface SyncService { } export function createSyncService(ctx: SyncServiceContext): SyncService { + if (!ctx.$) { + ctx.$ = createNodeShell() as unknown as SyncServiceContext['$']; + } + const locations = resolveSyncLocations(); const log = createLogger(ctx.client); const lockPath = path.join(path.dirname(locations.statePath), 'sync.lock'); @@ -475,8 +489,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } tursoIdleFlushTimer = setTimeout(() => { tursoIdleFlushTimer = null; - void skipIfBusy(async () => { + skipIfBusy(async () => { await flushQueuedTursoSync('idle-event'); + }).catch(() => { + // Errors are already logged internally by skipIfBusy }); }, 250); }; @@ -527,7 +543,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { stopTursoSyncLoop(); tursoSyncIntervalSec = nextInterval; tursoSyncTimer = setInterval(() => { - void skipIfBusy(async () => { + skipIfBusy(async () => { const latest = await loadSyncConfig(locations); if (!latest || !isTursoSessionBackend(latest)) { stopTursoSyncLoop(); @@ -542,6 +558,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { if (result.warning) { log.warn(result.warning, { reason: 'background' }); } + }).catch(() => { + // Errors are already logged internally by skipIfBusy }); }, nextInterval * 1000); }; @@ -600,17 +618,19 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } catch (error) { const message = `Failed to load opencode-synced config: ${formatError(error)}`; log.error(message, { path: locations.syncConfigPath }); - await showToast( + await notify( ctx.client, - `Failed to load opencode-synced config. Check ${locations.syncConfigPath} for JSON errors.`, + '❌', + `Failed to load config. Check ${locations.syncConfigPath} for JSON errors.`, 'error' ); return; } if (!config) { stopTursoSyncLoop(); - await showToast( + await notify( ctx.client, + 'ℹ️', 'Configure opencode-synced with /sync-init or link to an existing repo with /sync-link', 'info' ); @@ -618,6 +638,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } try { assertValidSecretsBackend(config); + await notify(ctx.client, '🔄', 'Syncing config...', 'info'); let tursoWarning: string | null = null; if (isTursoSessionBackend(config)) { try { @@ -632,13 +653,14 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { runSecretsPullIfConfigured, }); ensureTursoSyncLoop(config); + await notify(ctx.client, '✅', 'opencode-synced ready', 'success'); if (tursoWarning) { log.warn(tursoWarning); await showToast(ctx.client, tursoWarning, 'warning'); } } catch (error) { log.error('Startup sync failed', { error: formatError(error) }); - await showToast(ctx.client, formatError(error), 'error'); + await notify(ctx.client, '❌', formatError(error), 'error'); } }), status: async () => { @@ -796,6 +818,12 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } ensureTursoSyncLoop(config); + await notify( + ctx.client, + '🚀', + `Sync configured — ${repoIdentifier} (${resolveRepoBranch(config)})`, + 'success' + ); return lines.join('\n'); }), link: (options: LinkOptions) => @@ -811,6 +839,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); } + await notify(ctx.client, '🔗', 'Linking to sync repo...', 'info'); + const found = await findSyncRepo(ctx.$, options.repo, { disableAutoDiscovery: disableAutoRepoDiscovery, }); @@ -904,7 +934,12 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { lines.push('', ...linkNotes); } - await showToast(ctx.client, 'Config synced. Restart opencode to apply.', 'info'); + await notify( + ctx.client, + '🔗', + `Linked to ${found.owner}/${found.name}. Restart opencode to apply.`, + 'success' + ); return lines.join('\n'); }), pull: () => @@ -924,10 +959,13 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); } + await notify(ctx.client, '📥', 'Pulling config from GitHub...', 'info'); + const update = await fetchAndFastForward(ctx.$, repoRoot, branch); if (!update.updated) { const tursoSummary = await runForegroundTursoCycle(config, 'pull-up-to-date'); ensureTursoSyncLoop(config); + await notify(ctx.client, '📥', 'Already up to date', 'success'); if (tursoSummary) { return ['Already up to date.', tursoSummary].join('\n'); } @@ -939,6 +977,20 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await syncRepoToLocal(plan, overrides); await runSecretsPullIfConfigured(config); + if (config.includeSessions && !isTursoSessionBackend(config)) { + const sessionDbPath = path.join(locations.xdg.dataDir, 'opencode', 'opencode.db'); + const sessionsDir = path.join(repoRoot, 'data', 'sessions'); + const result = await syncSessions(sessionDbPath, sessionsDir); + log.info(`Session sync result: ${result.total} total, ${result.merged} merged`); + } + + if (config.includeProjects) { + syncGlobalData( + resolveProjectsFilePath(), + path.join(repoRoot, 'data', 'opencode.global.dat') + ); + } + await updateState(locations, { lastPull: new Date().toISOString(), lastRemoteUpdate: new Date().toISOString(), @@ -947,7 +999,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const tursoSummary = await runForegroundTursoCycle(config, 'pull-updated'); ensureTursoSyncLoop(config); - await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); + await notify(ctx.client, '📥', 'Config updated. Restart opencode to apply.', 'success'); if (tursoSummary) { return [ 'Remote config applied. Restart opencode to use new settings.', @@ -972,8 +1024,25 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); } + await notify(ctx.client, '📤', 'Pushing config to GitHub...', 'info'); + const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); + + if (config.includeSessions && !isTursoSessionBackend(config)) { + const sessionDbPath = path.join(locations.xdg.dataDir, 'opencode', 'opencode.db'); + const sessionsDir = path.join(repoRoot, 'data', 'sessions'); + const result = await syncSessions(sessionDbPath, sessionsDir); + log.info(`Session sync result: ${result.total} total, ${result.merged} merged`); + } + + if (config.includeProjects) { + syncGlobalData( + resolveProjectsFilePath(), + path.join(repoRoot, 'data', 'opencode.global.dat') + ); + } + await syncLocalToRepo(plan, overrides, { overridesPath: locations.overridesPath, allowMcpSecrets: canCommitMcpSecrets(config), @@ -985,6 +1054,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ensureTursoSyncLoop(config); try { const secretsResult = await runSecretsPushIfConfigured(config); + await notify(ctx.client, '📤', 'No local changes to push', 'info'); const lines: string[] = []; if (secretsResult === 'pushed') { lines.push('No local changes to push. Secrets updated.'); @@ -1007,7 +1077,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } } - const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot); + const now = new Date(); + const message = `Sync opencode config (${now.toISOString().slice(0, 10)})`; await commitAll(ctx.$, repoRoot, message); await pushBranch(ctx.$, repoRoot, branch); @@ -1026,6 +1097,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const tursoSummary = await runForegroundTursoCycle(config, 'push-updated'); ensureTursoSyncLoop(config); + await notify(ctx.client, '📤', `Pushed: ${message}`, 'success'); + if (secretsFailure) { const lines = [`Pushed changes: ${message}. Secrets push failed: ${secretsFailure}`]; if (tursoSummary) { @@ -1336,6 +1409,10 @@ function toRepoRelativePath(repoRoot: string, absolutePath: string): string { return path.relative(repoRoot, absolutePath).split(path.sep).join('/'); } +// EN: Startup sync — auto-commit dirty, HEAD-cache fetch skip, pull + session/project merge +// EN: github push is fire-and-forget (.catch) — doesn't block startup +// RU: Стартовая синхронизация — auto-commit при dirty, пропуск fetch по HEAD-кэшу, pull + merge сессий/проектов +// RU: push в GitHub — fire-and-forget (.catch) — не блокирует запуск async function runStartup( ctx: SyncServiceContext, locations: ReturnType, @@ -1355,34 +1432,86 @@ async function runStartup( const branch = await resolveBranch(ctx, config, repoRoot); log.debug('Resolved branch', { branch }); + const head = await getHeadHash(ctx.$, repoRoot); + const state = await loadState(locations); const dirty = await hasLocalChanges(ctx.$, repoRoot); if (dirty) { - log.warn('Uncommitted changes detected', { repoRoot }); - await showToast( - ctx.client, - `Uncommitted changes detected. Run /sync-resolve to auto-fix, or manually resolve in: ${repoRoot}`, - 'warning' - ); - return; + log.warn('Uncommitted changes detected, attempting auto-commit', { repoRoot }); + try { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, '.'); + await commitAll(ctx.$, repoRoot, `Sync opencode config (${date})`); + const branch = await resolveBranch(ctx, config, repoRoot); + const commitHead = await getHeadHash(ctx.$, repoRoot); + // EN: Background push — don't block startup waiting for GitHub + // RU: Фоновый push — не ждём GitHub, не блокируем стартап + pushBranch(ctx.$, repoRoot, branch).catch((err) => + log.warn('Background push failed', { error: formatError(err) }) + ); + await updateState(locations, { + lastPush: new Date().toISOString(), + lastHead: commitHead ?? undefined, + }); + log.info('Auto-committed and pushed pending changes'); + await showToast(ctx.client, 'Pending changes committed and pushed', 'info'); + } catch (error) { + log.warn('Could not auto-commit pending changes', { error: formatError(error) }); + await showToast( + ctx.client, + `Uncommitted changes detected. Run /sync-resolve to auto-fix, or manually resolve in: ${repoRoot}`, + 'warning' + ); + return; + } } - const update = await fetchAndFastForward(ctx.$, repoRoot, branch); - if (update.updated) { - log.info('Pulled remote changes', { branch }); - const overrides = await loadOverrides(locations); - const plan = buildSyncPlan(config, locations, repoRoot); - await syncRepoToLocal(plan, overrides); - await options.runSecretsPullIfConfigured(config); - await updateState(locations, { - lastPull: new Date().toISOString(), - lastRemoteUpdate: new Date().toISOString(), - }); - await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); - return; + const shouldFetch = !head || head !== state.lastHead || dirty; + if (shouldFetch) { + const update = await fetchAndFastForward(ctx.$, repoRoot, branch); + if (update.updated) { + log.info('Pulled remote changes', { branch }); + const overrides = await loadOverrides(locations); + const plan = buildSyncPlan(config, locations, repoRoot); + await syncRepoToLocal(plan, overrides); + await options.runSecretsPullIfConfigured(config); + + if (config.includeSessions && !isTursoSessionBackend(config)) { + const sessionDbPath = path.join(locations.xdg.dataDir, 'opencode', 'opencode.db'); + const sessionsDir = path.join(repoRoot, 'data', 'sessions'); + const result = await syncSessions(sessionDbPath, sessionsDir); + log.info(`Session sync result: ${result.total} total, ${result.merged} merged`); + } + + if (config.includeProjects) { + syncGlobalData( + resolveProjectsFilePath(), + path.join(repoRoot, 'data', 'opencode.global.dat') + ); + } + + await updateState(locations, { + lastPull: new Date().toISOString(), + lastRemoteUpdate: new Date().toISOString(), + lastHead: head ?? undefined, + }); + await showToast(ctx.client, 'Config updated. Restart opencode to apply.', 'info'); + return; + } } const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); + + if (config.includeSessions && !isTursoSessionBackend(config)) { + const sessionDbPath = path.join(locations.xdg.dataDir, 'opencode', 'opencode.db'); + const sessionsDir = path.join(repoRoot, 'data', 'sessions'); + const result = await syncSessions(sessionDbPath, sessionsDir); + log.info(`Session sync result: ${result.total} total, ${result.merged} merged`); + } + + if (config.includeProjects) { + syncGlobalData(resolveProjectsFilePath(), path.join(repoRoot, 'data', 'opencode.global.dat')); + } + await syncLocalToRepo(plan, overrides, { overridesPath: locations.overridesPath, allowMcpSecrets: canCommitMcpSecrets(config), @@ -1393,12 +1522,15 @@ async function runStartup( return; } - const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot); + const now = new Date(); + const message = `Sync opencode config (${now.toISOString().slice(0, 10)})`; log.info('Pushing local changes', { message }); await commitAll(ctx.$, repoRoot, message); await pushBranch(ctx.$, repoRoot, branch); + const newHead = await getHeadHash(ctx.$, repoRoot); await updateState(locations, { lastPush: new Date().toISOString(), + lastHead: newHead ?? undefined, }); } @@ -1599,11 +1731,21 @@ async function analyzeAndDecideResolution( if (sessionId) { try { await ctx.client.session.delete({ path: { id: sessionId } }); - } catch {} + } catch { + // Session deletion is best-effort cleanup + } } } } catch (error) { - console.error('[ERROR] AI resolution analysis failed:', error); + ctx.client.app + .log({ + body: { + service: 'opencode-synced', + level: 'error', + message: `AI resolution analysis failed: ${formatError(error)}`, + }, + }) + .catch(() => {}); return { action: 'manual', reason: `Error analyzing changes: ${formatError(error)}` }; } } diff --git a/src/sync/session-db.ts b/src/sync/session-db.ts new file mode 100644 index 0000000..99c3d84 --- /dev/null +++ b/src/sync/session-db.ts @@ -0,0 +1,523 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +export interface SessionMeta { + id: string; + project_id: string | null; + parent_id: string | null; + slug: string | null; + directory: string | null; + title: string | null; + version: number | null; + share_url: string | null; + summary_additions: number | null; + summary_deletions: number | null; + summary_files: number | null; + summary_diffs: string | null; + revert: string | null; + permission: string | null; + time_created: string | null; + time_updated: string | null; + time_compacting: string | null; + time_archived: string | null; + workspace_id: string | null; + path: string | null; + agent: string | null; + model: string | null; + cost: number | null; + tokens_input: number | null; + tokens_output: number | null; + tokens_reasoning: number | null; + tokens_cache_read: number | null; + tokens_cache_write: number | null; +} + +export interface Message { + id: string; + session_id: string; + time_created: string | null; + time_updated: string | null; + data: string | null; + parts: Part[]; +} + +export interface Part { + id: string; + message_id: string; + session_id: string; + time_created: string | null; + time_updated: string | null; + data: string | null; +} + +export interface SessionMessage { + id: string; + session_id: string; + type: string | null; + time_created: string | null; + time_updated: string | null; + data: string | null; +} + +export interface Todo { + session_id: string; + content: string | null; + status: string | null; + priority: number | null; + position: number | null; + time_created: string | null; + time_updated: string | null; +} + +export interface SessionShare { + session_id: string; + id: string; + secret: string | null; + url: string | null; + time_created: string | null; + time_updated: string | null; +} + +export interface Session { + session: SessionMeta; + messages: Message[]; + session_messages: SessionMessage[]; + todos: Todo[]; + session_shares: SessionShare[]; +} + +const SESSION_COLUMNS = [ + 'id', + 'project_id', + 'parent_id', + 'slug', + 'directory', + 'title', + 'version', + 'share_url', + 'summary_additions', + 'summary_deletions', + 'summary_files', + 'summary_diffs', + 'revert', + 'permission', + 'time_created', + 'time_updated', + 'time_compacting', + 'time_archived', + 'workspace_id', + 'path', + 'agent', + 'model', + 'cost', + 'tokens_input', + 'tokens_output', + 'tokens_reasoning', + 'tokens_cache_read', + 'tokens_cache_write', +]; + +const MESSAGE_COLUMNS = ['id', 'session_id', 'time_created', 'time_updated', 'data']; +const PART_COLUMNS = ['id', 'message_id', 'session_id', 'time_created', 'time_updated', 'data']; +const SESSION_MESSAGE_COLUMNS = [ + 'id', + 'session_id', + 'type', + 'time_created', + 'time_updated', + 'data', +]; +const TODO_COLUMNS = [ + 'session_id', + 'content', + 'status', + 'priority', + 'position', + 'time_created', + 'time_updated', +]; +const SHARE_COLUMNS = ['session_id', 'id', 'secret', 'url', 'time_created', 'time_updated']; + +function openDB(dbPath: string): DatabaseSync { + return new DatabaseSync(dbPath); +} + +// EN: Database handle variants — used by syncSessions to open DB once +// RU: Варианты с проброшенным handle — syncSessions открывает БД один раз +// EN: Force WAL checkpoint so subsequent reads see all committed writes +// RU: Принудительный WAL checkpoint, чтобы последующие чтения видели все записанные данные +export function checkpointDB(db: DatabaseSync): void { + try { + db.exec('PRAGMA wal_checkpoint'); + } catch { + // checkpoint may fail if another connection has a write lock — ignore + } +} + +function readSessionMeta(db: DatabaseSync, id: string): SessionMeta | null { + const row = db + .prepare(`SELECT ${SESSION_COLUMNS.join(', ')} FROM session WHERE id = ?`) + .get(id) as Record | undefined; + if (!row) return null; + return row as unknown as SessionMeta; +} + +function readMessages(db: DatabaseSync, sessionId: string): Message[] { + const rows = db + .prepare(`SELECT ${MESSAGE_COLUMNS.join(', ')} FROM message WHERE session_id = ?`) + .all(sessionId) as Record[]; + return rows.map((row) => { + const msg = row as unknown as Message; + const partRows = db + .prepare(`SELECT ${PART_COLUMNS.join(', ')} FROM part WHERE message_id = ?`) + .all(msg.id) as Record[]; + msg.parts = partRows.map((pr) => pr as unknown as Part); + return msg; + }); +} + +function readSessionMessages(db: DatabaseSync, sessionId: string): SessionMessage[] { + const rows = db + .prepare( + `SELECT ${SESSION_MESSAGE_COLUMNS.join(', ')} FROM session_message WHERE session_id = ?` + ) + .all(sessionId) as Record[]; + return rows.map((r) => r as unknown as SessionMessage); +} + +function readTodos(db: DatabaseSync, sessionId: string): Todo[] { + const rows = db + .prepare(`SELECT ${TODO_COLUMNS.join(', ')} FROM todo WHERE session_id = ?`) + .all(sessionId) as Record[]; + return rows.map((r) => r as unknown as Todo); +} + +function readShares(db: DatabaseSync, sessionId: string): SessionShare[] { + const rows = db + .prepare(`SELECT ${SHARE_COLUMNS.join(', ')} FROM session_share WHERE session_id = ?`) + .all(sessionId) as Record[]; + return rows.map((r) => r as unknown as SessionShare); +} + +// EN: List session IDs using an already-open DB handle (avoids open/close overhead) +// RU: Список ID сессий через уже открытый DB handle (без лишних open/close) +export function listSessionIdsFromHandle(db: DatabaseSync): string[] { + const rows = db.prepare('SELECT id FROM session').all() as { id: string }[]; + return rows.map((r) => r.id); +} + +export function listSessionIdsFromDB(dbPath: string): string[] { + if (!fs.existsSync(dbPath)) return []; + const db = openDB(dbPath); + try { + return listSessionIdsFromHandle(db); + } finally { + db.close(); + } +} + +export function listSessionIdsFromDir(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')); +} + +// EN: Read session + all relations (messages, parts, todos, shares) via open handle +// RU: Чтение сессии + всех связей (сообщения, части, todo, шары) через открытый handle +export function readSessionFromHandle(db: DatabaseSync, id: string): Session | null { + const meta = readSessionMeta(db, id); + if (!meta) return null; + return { + session: meta, + messages: readMessages(db, id), + session_messages: readSessionMessages(db, id), + todos: readTodos(db, id), + session_shares: readShares(db, id), + }; +} + +export function readSessionFromDB(dbPath: string, id: string): Session | null { + if (!fs.existsSync(dbPath)) return null; + const db = openDB(dbPath); + try { + return readSessionFromHandle(db, id); + } finally { + db.close(); + } +} + +export function readSessionFromFile(dir: string, id: string): Session | null { + const filePath = path.join(dir, `${id}.json`); + if (!fs.existsSync(filePath)) return null; + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(content); + + if (Array.isArray(parsed.session) && parsed.columns?.session) { + const session = arrayToObject(parsed.session, parsed.columns.session); + const messages = (parsed.message ?? []).map((row: unknown[]) => { + const msg = arrayToObject(row, parsed.columns.message); + (msg as Record).parts = (parsed.parts ?? []) + .filter((p: unknown[]) => p[1] === msg.id) + .map((p: unknown[]) => arrayToObject(p, parsed.columns.parts)); + return msg; + }); + const session_messages = (parsed.session_messages ?? []).map((row: unknown[]) => + arrayToObject(row, parsed.columns.session_message) + ); + return { + session: session as unknown as SessionMeta, + messages, + session_messages, + todos: [], + session_shares: [], + } as Session; + } + + return { + session: parsed.session ?? parsed, + messages: parsed.messages ?? [], + session_messages: parsed.session_messages ?? [], + todos: parsed.todos ?? [], + session_shares: parsed.session_shares ?? [], + } as Session; + } catch { + // EN: Backup corrupted file before returning null (data recovery safety net) + // RU: Бэкап повреждённого файла перед возвратом null (страховка от потери данных) + try { + const brokenPath = `${filePath}.broken`; + if (!fs.existsSync(brokenPath)) { + fs.copyFileSync(filePath, brokenPath); + } + } catch { + // can't backup either — ignore + } + return null; + } +} + +export function readSessionsFromDB(dbPath: string): Session[] { + if (!fs.existsSync(dbPath)) return []; + const db = openDB(dbPath); + try { + const ids = db.prepare('SELECT id FROM session').all() as { id: string }[]; + return ids.map(({ id }) => ({ + session: readSessionMeta(db, id) as SessionMeta, + messages: readMessages(db, id), + session_messages: readSessionMessages(db, id), + todos: readTodos(db, id), + session_shares: readShares(db, id), + })); + } finally { + db.close(); + } +} + +function arrayToObject(arr: unknown[], colNames: string[]): Record { + const obj: Record = {}; + for (let i = 0; i < colNames.length; i++) { + obj[colNames[i]] = arr[i] ?? null; + } + return obj; +} + +export function readSessionsFromDir(dir: string): Session[] { + if (!fs.existsSync(dir)) return []; + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); + return files.map((file) => { + const content = fs.readFileSync(path.join(dir, file), 'utf-8'); + const parsed = JSON.parse(content); + + if (Array.isArray(parsed.session) && parsed.columns?.session) { + const session = arrayToObject(parsed.session, parsed.columns.session); + const messages = (parsed.message ?? []).map((row: unknown[]) => { + const msg = arrayToObject(row, parsed.columns.message); + (msg as Record).parts = (parsed.parts ?? []) + .filter((p: unknown[]) => p[1] === msg.id) + .map((p: unknown[]) => arrayToObject(p, parsed.columns.parts)); + return msg; + }); + const session_messages = (parsed.session_messages ?? []).map((row: unknown[]) => + arrayToObject(row, parsed.columns.session_message) + ); + return { + session: session as unknown as SessionMeta, + messages, + session_messages, + todos: [], + session_shares: [], + } as Session; + } + + return { + session: parsed.session ?? parsed, + messages: parsed.messages ?? [], + session_messages: parsed.session_messages ?? [], + todos: parsed.todos ?? [], + session_shares: parsed.session_shares ?? [], + } as Session; + }); +} + +function asSQLValue(val: unknown): string | number | null { + if (val === null || val === undefined) return null; + if (typeof val === 'string') return val; + if (typeof val === 'number') return val; + if (typeof val === 'boolean') return val ? 1 : 0; + return JSON.stringify(val); +} + +function upsertSession(db: DatabaseSync, s: SessionMeta): void { + const values = SESSION_COLUMNS.map((col) => + asSQLValue((s as unknown as Record)[col]) + ); + try { + db.prepare( + `INSERT OR REPLACE INTO session (${SESSION_COLUMNS.join(', ')}) VALUES (${SESSION_COLUMNS.map(() => '?').join(',')})` + ).run(...values); + } catch (e) { + throw new Error(`upsertSession failed for session ${s.id}: ${e}`); + } +} + +function upsertMessages(db: DatabaseSync, messages: Message[]): void { + const placeholders = MESSAGE_COLUMNS.map(() => '?').join(', '); + const stmt = db.prepare( + `INSERT OR REPLACE INTO message (${MESSAGE_COLUMNS.join(', ')}) VALUES (${placeholders})` + ); + for (const msg of messages) { + try { + stmt.run(v(msg.id), v(msg.session_id), v(msg.time_created), v(msg.time_updated), v(msg.data)); + } catch (e) { + throw new Error(`upsertMessages failed for msg ${msg.id}: ${e}`); + } + } +} + +function upsertParts(db: DatabaseSync, parts: Part[]): void { + const placeholders = PART_COLUMNS.map(() => '?').join(', '); + const stmt = db.prepare( + `INSERT OR REPLACE INTO part (${PART_COLUMNS.join(', ')}) VALUES (${placeholders})` + ); + for (const part of parts) { + try { + stmt.run( + v(part.id), + v(part.message_id), + v(part.session_id), + v(part.time_created), + v(part.time_updated), + v(part.data) + ); + } catch (e) { + throw new Error(`upsertParts failed for part ${part.id}: ${e}`); + } + } +} + +function v(val: unknown): string | number | null { + return asSQLValue(val); +} + +function upsertSessionMessages(db: DatabaseSync, items: SessionMessage[]): void { + const placeholders = SESSION_MESSAGE_COLUMNS.map(() => '?').join(', '); + const stmt = db.prepare( + `INSERT OR REPLACE INTO session_message (${SESSION_MESSAGE_COLUMNS.join(', ')}) VALUES (${placeholders})` + ); + for (const sm of items) { + stmt.run( + v(sm.id), + v(sm.session_id), + v(sm.type), + v(sm.time_created), + v(sm.time_updated), + v(sm.data) + ); + } +} + +function upsertTodos(db: DatabaseSync, items: Todo[]): void { + const placeholders = TODO_COLUMNS.map(() => '?').join(', '); + const stmt = db.prepare( + `INSERT OR REPLACE INTO todo (${TODO_COLUMNS.join(', ')}) VALUES (${placeholders})` + ); + for (const todo of items) { + stmt.run( + v(todo.session_id), + v(todo.content), + v(todo.status), + v(todo.priority), + v(todo.position), + v(todo.time_created), + v(todo.time_updated) + ); + } +} + +function upsertShares(db: DatabaseSync, items: SessionShare[]): void { + const placeholders = SHARE_COLUMNS.map(() => '?').join(', '); + const stmt = db.prepare( + `INSERT OR REPLACE INTO session_share (${SHARE_COLUMNS.join(', ')}) VALUES (${placeholders})` + ); + for (const share of items) { + stmt.run( + v(share.session_id), + v(share.id), + v(share.secret), + v(share.url), + v(share.time_created), + v(share.time_updated) + ); + } +} + +// EN: Batch-write multiple sessions in one transaction (avoids per-session DB open/close) +// RU: Пакетная запись нескольких сессий одной транзакцией (без per-session open/close БД) +export function writeSessionsToHandle(db: DatabaseSync, sessions: Session[]): void { + if (sessions.length === 0) return; + db.exec('BEGIN TRANSACTION'); + try { + for (const s of sessions) { + upsertSession(db, s.session); + upsertMessages(db, s.messages); + for (const msg of s.messages) { + upsertParts(db, msg.parts); + } + upsertSessionMessages(db, s.session_messages); + upsertTodos(db, s.todos); + upsertShares(db, s.session_shares); + } + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +export function writeSessionsToDB(dbPath: string, sessions: Session[]): void { + if (sessions.length === 0) return; + const db = openDB(dbPath); + try { + writeSessionsToHandle(db, sessions); + } finally { + db.close(); + } +} + +// EN: Write single session to JSON file (compact format, no pretty-print — saves ~30% disk) +// RU: Запись одной сессии в JSON-файл (compact, без pretty-print — экономия ~30% места) +export function writeSessionToFile(dir: string, session: Session): void { + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${session.session.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(session), 'utf-8'); +} + +export function writeSessionsToDir(dir: string, sessions: Session[]): void { + fs.mkdirSync(dir, { recursive: true }); + for (const s of sessions) { + writeSessionToFile(dir, s); + } +} diff --git a/src/sync/session-merge.ts b/src/sync/session-merge.ts new file mode 100644 index 0000000..1f39375 --- /dev/null +++ b/src/sync/session-merge.ts @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import type { Message, Part, Session, Todo } from './session-db.js'; +import { + checkpointDB, + listSessionIdsFromDir, + listSessionIdsFromHandle, + readSessionFromFile, + readSessionFromHandle, + writeSessionsToHandle, + writeSessionToFile, +} from './session-db.js'; + +export interface SyncSessionResult { + total: number; + merged: number; + conflicts: number; +} + +// EN: Pick the newer record by time_updated (null treated as oldest) +// RU: Выбор более новой записи по time_updated (null считается старым) +function pickNewer(a: T, b: T): T { + if (!a.time_updated) return b; + if (!b.time_updated) return a; + return a.time_updated >= b.time_updated ? a : b; +} + +function unionMessages(local: Message[], remote: Message[]): Message[] { + const map = new Map(); + for (const msg of local) map.set(msg.id, msg); + for (const msg of remote) { + const existing = map.get(msg.id); + if (!existing) { + map.set(msg.id, msg); + } else { + const winner = pickNewer(existing, msg); + winner.parts = unionParts(existing.parts, msg.parts); + map.set(msg.id, winner); + } + } + return [...map.values()]; +} + +function unionParts(local: Part[], remote: Part[]): Part[] { + const map = new Map(); + for (const part of local) map.set(part.id, part); + for (const part of remote) { + const existing = map.get(part.id); + map.set(part.id, existing ? pickNewer(existing, part) : part); + } + return [...map.values()]; +} + +function unionById( + local: T[], + remote: T[] +): T[] { + const map = new Map(); + for (const item of local) map.set(item.id, item); + for (const item of remote) { + const existing = map.get(item.id); + map.set(item.id, existing ? pickNewer(existing, item) : item); + } + return [...map.values()]; +} + +function unionTodos(local: Todo[], remote: Todo[]): Todo[] { + const map = new Map(); + for (const item of local) { + const key = `${item.session_id}:${item.content ?? ''}`; + map.set(key, item); + } + for (const item of remote) { + const key = `${item.session_id}:${item.content ?? ''}`; + const existing = map.get(key); + map.set(key, existing ? pickNewer(existing, item) : item); + } + return [...map.values()]; +} + +// EN: Union-merge two session versions — picks newer session metadata + unions all relations by id +// RU: Union-merge двух версий сессии — берёт новую мету, объединяет все связи по id +function merge(local: Session, remote: Session): Session { + const localT = local.session.time_updated ?? ''; + const remoteT = remote.session.time_updated ?? ''; + const mergedSession = localT >= remoteT ? local.session : remote.session; + + return { + session: mergedSession, + messages: unionMessages(local.messages, remote.messages), + session_messages: unionById(local.session_messages, remote.session_messages), + todos: unionTodos(local.todos, remote.todos), + session_shares: unionById(local.session_shares, remote.session_shares), + }; +} + +// EN: Stream-based session sync — opens DB once, processes sessions one-by-one, batch-writes at end +// EN: Single DB connection eliminates per-session open/close overhead (was N+2 open/close per cycle) +// RU: Потоковая синхронизация — одно открытие БД, обработка сессий по одной, batch-запись в конце +// RU: Одно соединение с БД устраняет per-session накладные расходы (было N+2 open/close за цикл) +export async function syncSessions( + dbPath: string, + sessionsDir: string +): Promise { + const remoteIds = listSessionIdsFromDir(sessionsDir); + + const dbExists = fs.existsSync(dbPath); + const db = dbExists ? new DatabaseSync(dbPath) : null; + if (db) { + checkpointDB(db); + } + + try { + const localIds = db ? listSessionIdsFromHandle(db) : []; + const allIds = new Set([...localIds, ...remoteIds]); + let totalMerged = 0; + const hasRemote = remoteIds.length > 0; + const toWrite: Session[] = []; + + for (const id of allIds) { + const local = db ? readSessionFromHandle(db, id) : null; + const remote = readSessionFromFile(sessionsDir, id); + + let merged: Session; + + if (!local) { + merged = structuredClone(remote as Session); + } else if (!remote) { + merged = structuredClone(local); + } else { + totalMerged++; + merged = merge(local, remote); + } + + if (hasRemote && local) { + toWrite.push(merged); + } + writeSessionToFile(sessionsDir, merged); + } + + if (db && toWrite.length > 0) { + writeSessionsToHandle(db, toWrite); + } + + return { + total: allIds.size, + merged: totalMerged, + conflicts: 0, + }; + } finally { + db?.close(); + } +} diff --git a/src/sync/shell.ts b/src/sync/shell.ts new file mode 100644 index 0000000..cbfff22 --- /dev/null +++ b/src/sync/shell.ts @@ -0,0 +1,103 @@ +import { exec } from 'node:child_process'; + +export interface ShellProcessPromise + extends Promise<{ exitCode: number; stdout: string; stderr: string }> { + quiet(): this; + text(): Promise; +} + +export type ShellFn = (strings: TemplateStringsArray, ...values: unknown[]) => ShellProcessPromise; + +// EN: Build shell command string — wraps all interpolated values in double quotes +// EN: Prevents path/commit-message breakage on Windows (spaces in paths, etc.) +// RU: Сборка команды — все интерполированные значения оборачиваются в двойные кавычки +// RU: Предотвращает поломку путей/commit message на Windows (пробелы в путях, и т.д.) +function buildCommand(strings: TemplateStringsArray, ...values: unknown[]): string { + let cmd = ''; + for (let i = 0; i < strings.length; i++) { + cmd += strings[i]; + if (i < values.length) { + const v = values[i]; + const str = typeof v === 'string' ? v : String(v); + cmd += `"${str.replace(/"/g, '\\"')}"`; + } + } + return cmd.trim(); +} + +export function createNodeShell(): ShellFn { + return (strings: TemplateStringsArray, ...values: unknown[]): ShellProcessPromise => { + const command = buildCommand(strings, ...values); + let isQuiet = false; + + const run = (): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + return new Promise((resolve) => { + const child = exec( + command, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (error) { + resolve({ + exitCode: error.code ?? 1, + stdout: stdout ?? '', + stderr: stderr ?? '', + }); + } else { + resolve({ exitCode: 0, stdout: stdout ?? '', stderr: '' }); + } + } + ); + if (!isQuiet) { + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); + } + }); + }; + + let promise: Promise<{ exitCode: number; stdout: string; stderr: string }> | null = null; + const getPromise = (): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + if (!promise) { + promise = run(); + } + return promise; + }; + + const shellPromise = { + // biome-ignore lint/suspicious/noThenProperty: thenable pattern for async shell execution + then( + onfulfilled?: + | ((value: { + exitCode: number; + stdout: string; + stderr: string; + }) => TResult1 | PromiseLike) + | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return getPromise().then(onfulfilled, onrejected); + }, + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise<{ exitCode: number; stdout: string; stderr: string } | TResult> { + return getPromise().catch(onrejected); + }, + finally( + onfinally?: (() => void) | null + ): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return getPromise().finally(onfinally); + }, + get [Symbol.toStringTag]() { + return 'ShellProcessPromise'; + }, + quiet(): ShellProcessPromise { + isQuiet = true; + return this as unknown as ShellProcessPromise; + }, + text(): Promise { + return getPromise().then((r) => r.stdout); + }, + } as ShellProcessPromise; + + return shellPromise; + }; +} diff --git a/src/sync/turso.test.ts b/src/sync/turso.test.ts index cffbee9..9d4cec4 100644 --- a/src/sync/turso.test.ts +++ b/src/sync/turso.test.ts @@ -87,14 +87,14 @@ describe('isRetryableTursoError', () => { describe('path helpers', () => { it('resolves credential path and session db paths', () => { const locations = createLocations(); - expect(resolveTursoCredentialPath(locations)).toBe( + const p = (s: string) => s.replace(/\\/g, '/'); + expect(p(resolveTursoCredentialPath(locations))).toBe( '/home/test/.local/share/opencode/opencode-synced/turso-session.json' ); - expect(resolveSessionDbPaths(locations)).toEqual({ - dbPath: '/home/test/.local/share/opencode/opencode.db', - walPath: '/home/test/.local/share/opencode/opencode.db-wal', - shmPath: '/home/test/.local/share/opencode/opencode.db-shm', - }); + const result = resolveSessionDbPaths(locations); + expect(p(result.dbPath)).toBe('/home/test/.local/share/opencode/opencode.db'); + expect(p(result.walPath)).toBe('/home/test/.local/share/opencode/opencode.db-wal'); + expect(p(result.shmPath)).toBe('/home/test/.local/share/opencode/opencode.db-shm'); }); }); diff --git a/src/sync/utils.ts b/src/sync/utils.ts index 5b1af50..8655fcd 100644 --- a/src/sync/utils.ts +++ b/src/sync/utils.ts @@ -52,6 +52,20 @@ export async function showToast( } } +type NotifyVariant = 'info' | 'success' | 'warning' | 'error'; + +export async function notify( + client: Client, + emoji: string, + message: string, + variant: NotifyVariant = 'info' +): Promise { + const full = `${emoji} ${message}`; + const level = variant === 'error' ? 'error' : variant === 'warning' ? 'warn' : 'info'; + log(client, level, full); + await showToast(client, full, variant); +} + export function unwrapData(response: unknown): T | null { if (!response || typeof response !== 'object') return null; const maybeError = (response as { error?: unknown }).error;