Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d1bc84c
chore: Phase 0 — repo init (local deps, AGENTS.md with full plan)
Day-Anger May 22, 2026
fbcb046
fix: rename .mise/tasks/lint:fix to lint-fix for Windows compatibility
Day-Anger May 22, 2026
b154765
feat: Phase 1 — XDG paths on all platforms, remove win32 APPDATA branch
Day-Anger May 22, 2026
c8ab495
feat: Phase 2 — notifications for sync lifecycle events
Day-Anger May 22, 2026
d49f324
feat: Phase 3 — session union-merge per record
Day-Anger May 22, 2026
cd2a97f
feat: Phase 4 — includeProjects sync for opencode.global.dat
Day-Anger May 22, 2026
2d7c51f
fix: add createNodeShell() fallback for Desktop where ctx.$ is undefined
Day-Anger May 22, 2026
cca5513
fix: wrap all SQLite insert params with asSQLValue() to handle undefi…
Day-Anger May 22, 2026
e53b751
feat: merge-логика для opencode.global.dat
Day-Anger May 22, 2026
4e9e161
fix: skip writeSessionsToDB when remote is empty
Day-Anger May 22, 2026
8836dbc
fix: use INSERT OR REPLACE instead of DELETE+INSERT in writeSessionsToDB
Day-Anger May 22, 2026
df2be57
fix: handle old-format session JSON files in readSessionsFromDir
Day-Anger May 22, 2026
b1d1310
fix: remove full DB copy and storage dirs from sync plan
Day-Anger May 22, 2026
707b8d8
chore: lint fixes, error handling polish, production readiness
Day-Anger May 23, 2026
111e219
fix: cross-platform Windows compatibility and database fixes
Day-Anger May 23, 2026
d1f4ef5
fix: remove package-lock.json from tracked files
Day-Anger May 23, 2026
fe08ab1
chore: ignore package-lock.json
Day-Anger May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ coverage/
__pycache__/
opencode-plugin-template/
opencode-docs-*/
package-lock.json
package-lock.json
Comment on lines +16 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redundant duplicate entry for package-lock.json.

package-lock.json

2 changes: 1 addition & 1 deletion .mise/tasks/lint:fix → .mise/tasks/lint-fix
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
#MISE description="Run Biome lint with auto-fix"
biome lint --write .
biome lint --write .
18 changes: 14 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,7 +109,9 @@ async function loadCommands(): Promise<ParsedCommand[]> {
frontmatter,
template: body,
});
} catch {}
} catch {
// Skip malformed command files
}
}

return commands;
Expand Down Expand Up @@ -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 {
Expand Down
89 changes: 87 additions & 2 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Dirent } from 'node:fs';
import { promises as fs } from 'node:fs';
import path from 'node:path';

Expand Down Expand Up @@ -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<FileEntry[]> {
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<void> {
const [sourceFiles, destFiles] = await Promise.all([walkDir(sourceRoot), walkDir(destRoot)]);

const sourceMap = new Map<string, FileEntry>();
for (const f of sourceFiles) sourceMap.set(f.relativePath, f);

const destMap = new Map<string, FileEntry>();
for (const f of destFiles) destMap.set(f.relativePath, f);

const actions: Array<() => Promise<void>> = [];

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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/sync/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/sync/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface SyncConfig {
includeModelFavorites?: boolean;
includeOpencodeSkills?: boolean;
includeAgentsDir?: boolean;
includeProjects?: boolean;
secretsBackend?: SecretsBackendConfig;
extraSecretPaths?: string[];
extraConfigPaths?: string[];
Expand All @@ -75,6 +76,7 @@ export interface NormalizedSyncConfig extends SyncConfig {
includeModelFavorites: boolean;
includeOpencodeSkills: boolean;
includeAgentsDir: boolean;
includeProjects: boolean;
secretsBackend?: SecretsBackendConfig;
extraSecretPaths: string[];
extraConfigPaths: string[];
Expand All @@ -87,6 +89,7 @@ export interface SyncState {
lastSecretsHash?: string;
lastSessionPull?: string;
lastSessionPush?: string;
lastHead?: string;
}

export async function pathExists(filePath: string): Promise<boolean> {
Expand Down Expand Up @@ -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,
Expand All @@ -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 : [],
Expand Down
30 changes: 9 additions & 21 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 = {
Expand All @@ -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', () => {
Expand Down
Loading
Loading