Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 2 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"files": ["dist"],
"dependencies": {
"@opencode-ai/plugin": "1.0.85"
},
Expand All @@ -51,8 +49,6 @@
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,json}": [
"biome check --write --no-errors-on-unmatched"
]
"*.{js,ts,json}": ["biome check --write --no-errors-on-unmatched"]
}
}
7 changes: 4 additions & 3 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';

import {
chmodIfExists,
deepMerge,
hasOwn,
parseJsonc,
Expand Down Expand Up @@ -182,7 +183,7 @@ async function copyFileWithMode(sourcePath: string, destinationPath: string): Pr
const stat = await fs.stat(sourcePath);
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.copyFile(sourcePath, destinationPath);
await fs.chmod(destinationPath, stat.mode & 0o777);
await chmodIfExists(destinationPath, stat.mode & 0o777);
}

async function copyDirRecursive(sourcePath: string, destinationPath: string): Promise<void> {
Expand All @@ -204,7 +205,7 @@ async function copyDirRecursive(sourcePath: string, destinationPath: string): Pr
}
}

await fs.chmod(destinationPath, stat.mode & 0o777);
await chmodIfExists(destinationPath, stat.mode & 0o777);
}

async function removePath(targetPath: string): Promise<void> {
Expand Down Expand Up @@ -235,7 +236,7 @@ async function applyExtraSecrets(plan: SyncPlan, fromRepo: boolean): Promise<voi
if (fromRepo) {
await copyFileWithMode(repoPath, localPath);
if (entry.mode !== undefined) {
await fs.chmod(localPath, entry.mode);
await chmodIfExists(localPath, entry.mode);
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/sync/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { describe, expect, it } from 'vitest';

import {
canCommitMcpSecrets,
chmodIfExists,
deepMerge,
normalizeSyncConfig,
parseJsonc,
Expand Down Expand Up @@ -98,3 +103,15 @@ describe('parseJsonc', () => {
});
});
});

describe('chmodIfExists', () => {
it('ignores missing paths', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-'));
try {
const missingPath = path.join(tempDir, 'missing.txt');
await expect(chmodIfExists(missingPath, 0o600)).resolves.toBeUndefined();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
12 changes: 11 additions & 1 deletion src/sync/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ export async function pathExists(filePath: string): Promise<boolean> {
}
}

export async function chmodIfExists(filePath: string, mode: number): Promise<void> {
try {
await fs.chmod(filePath, mode);
} catch (error) {
const maybeErrno = error as NodeJS.ErrnoException;
if (maybeErrno.code === 'ENOENT') return;
throw error;
}
}

export function normalizeSyncConfig(config: SyncConfig): SyncConfig {
const includeSecrets = Boolean(config.includeSecrets);
return {
Expand Down Expand Up @@ -247,7 +257,7 @@ export async function writeJsonFile(
const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`;
await fs.writeFile(filePath, content, 'utf8');
if (options.mode !== undefined) {
await fs.chmod(filePath, options.mode);
await chmodIfExists(filePath, options.mode);
}
}

Expand Down
78 changes: 78 additions & 0 deletions src/sync/lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { describe, expect, it } from 'vitest';

import { tryAcquireSyncLock } from './lock.js';

describe('tryAcquireSyncLock', () => {
it('acquires and releases a lock', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-lock-'));
try {
const lockPath = path.join(tempDir, 'sync.lock');
const result = await tryAcquireSyncLock(lockPath);
expect(result.acquired).toBe(true);
if (result.acquired) {
await result.release();
}

const second = await tryAcquireSyncLock(lockPath);
expect(second.acquired).toBe(true);
if (second.acquired) {
await second.release();
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it('returns busy when lock is held by alive pid', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-lock-'));
try {
const lockPath = path.join(tempDir, 'sync.lock');
await writeFile(
lockPath,
`${JSON.stringify(
{ pid: process.pid, startedAt: new Date().toISOString(), hostname: os.hostname() },
null,
2
)}\n`,
'utf8'
);

const result = await tryAcquireSyncLock(lockPath);
expect(result.acquired).toBe(false);
if (!result.acquired) {
expect(result.info?.pid).toBe(process.pid);
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});

it('breaks stale lock when pid is dead', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-lock-'));
try {
const lockPath = path.join(tempDir, 'sync.lock');
const deadPid = process.pid + 1_000_000;
await writeFile(
lockPath,
`${JSON.stringify(
{ pid: deadPid, startedAt: new Date(0).toISOString(), hostname: os.hostname() },
null,
2
)}\n`,
'utf8'
);

const result = await tryAcquireSyncLock(lockPath);
expect(result.acquired).toBe(true);
if (result.acquired) {
await result.release();
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
109 changes: 109 additions & 0 deletions src/sync/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { mkdir, open, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

export interface SyncLockInfo {
pid: number;
startedAt: string;
hostname: string;
}

export type SyncLockResult =
| { acquired: true; info: SyncLockInfo; release: () => Promise<void> }
| { acquired: false; info: SyncLockInfo | null };

function isProcessAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;

try {
process.kill(pid, 0);
return true;
} catch (error) {
const maybeErrno = error as NodeJS.ErrnoException;
if (maybeErrno.code === 'ESRCH') return false;
return true;
}
}

async function readLockInfo(lockPath: string): Promise<SyncLockInfo | null> {
try {
const content = await readFile(lockPath, 'utf8');
const parsed = JSON.parse(content) as Partial<SyncLockInfo>;
if (typeof parsed.pid !== 'number') return null;
if (typeof parsed.startedAt !== 'string') return null;
if (typeof parsed.hostname !== 'string') return null;

return {
pid: parsed.pid,
startedAt: parsed.startedAt,
hostname: parsed.hostname,
};
} catch (error) {
const maybeErrno = error as NodeJS.ErrnoException;
if (maybeErrno.code === 'ENOENT') return null;
return null;
}
}

export async function tryAcquireSyncLock(lockPath: string): Promise<SyncLockResult> {
const parentDir = path.dirname(lockPath);
await mkdir(parentDir, { recursive: true });

const ourInfo: SyncLockInfo = {
pid: process.pid,
startedAt: new Date().toISOString(),
hostname: os.hostname(),
};

let attempt = 0;
while (true) {
try {
const handle = await open(lockPath, 'wx');
await handle.writeFile(`${JSON.stringify(ourInfo, null, 2)}\n`, 'utf8');

return {
acquired: true,
info: ourInfo,
release: async () => {
await handle.close().catch(() => {
// ignore close failures
});
await rm(lockPath, { force: true });
},
};
} catch (error) {
const maybeErrno = error as NodeJS.ErrnoException;
if (maybeErrno.code !== 'EEXIST') throw error;

const existing = await readLockInfo(lockPath);

const shouldBreakLock = existing === null || !isProcessAlive(existing.pid);
if (attempt === 0 && shouldBreakLock) {
await rm(lockPath, { force: true });
attempt += 1;
continue;
}

return { acquired: false, info: existing };
}
}
}

export async function withSyncLock<T>(
lockPath: string,
options: {
onBusy: (info: SyncLockInfo | null) => T | Promise<T>;
},
fn: () => Promise<T>
): Promise<T> {
const result = await tryAcquireSyncLock(lockPath);
if (!result.acquired) {
return await options.onBusy(result.info);
}

try {
return await fn();
} finally {
await result.release();
}
}
Loading
Loading