opencode-synced 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,12 +28,7 @@ Enable the plugin in your global OpenCode config (OpenCode will install it on ne
28
28
  }
29
29
  ```
30
30
 
31
- OpenCode does not auto-update plugins. To update, remove the cached plugin and restart OpenCode:
32
-
33
- ```bash
34
- rm -rf ~/.cache/opencode/node_modules/opencode-synced
35
- opencode
36
- ```
31
+ OpenCode does not auto-update plugins. To update, modify the version number in your config file.
37
32
 
38
33
  ## Configure
39
34
 
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { deepMerge, hasOwn, parseJsonc, pathExists, stripOverrides, writeJsonFile, } from './config.js';
3
+ import { chmodIfExists, deepMerge, hasOwn, parseJsonc, pathExists, stripOverrides, writeJsonFile, } from './config.js';
4
4
  import { extractMcpSecrets, hasOverrides, mergeOverrides, stripOverrideKeys, } from './mcp-secrets.js';
5
5
  import { normalizePath } from './paths.js';
6
6
  export async function syncRepoToLocal(plan, overrides) {
@@ -117,7 +117,7 @@ async function copyFileWithMode(sourcePath, destinationPath) {
117
117
  const stat = await fs.stat(sourcePath);
118
118
  await fs.mkdir(path.dirname(destinationPath), { recursive: true });
119
119
  await fs.copyFile(sourcePath, destinationPath);
120
- await fs.chmod(destinationPath, stat.mode & 0o777);
120
+ await chmodIfExists(destinationPath, stat.mode & 0o777);
121
121
  }
122
122
  async function copyDirRecursive(sourcePath, destinationPath) {
123
123
  const stat = await fs.stat(sourcePath);
@@ -134,7 +134,7 @@ async function copyDirRecursive(sourcePath, destinationPath) {
134
134
  await copyFileWithMode(entrySource, entryDest);
135
135
  }
136
136
  }
137
- await fs.chmod(destinationPath, stat.mode & 0o777);
137
+ await chmodIfExists(destinationPath, stat.mode & 0o777);
138
138
  }
139
139
  async function removePath(targetPath) {
140
140
  await fs.rm(targetPath, { recursive: true, force: true });
@@ -161,7 +161,7 @@ async function applyExtraSecrets(plan, fromRepo) {
161
161
  if (fromRepo) {
162
162
  await copyFileWithMode(repoPath, localPath);
163
163
  if (entry.mode !== undefined) {
164
- await fs.chmod(localPath, entry.mode);
164
+ await chmodIfExists(localPath, entry.mode);
165
165
  }
166
166
  }
167
167
  }
@@ -20,6 +20,7 @@ export interface SyncState {
20
20
  lastRemoteUpdate?: string;
21
21
  }
22
22
  export declare function pathExists(filePath: string): Promise<boolean>;
23
+ export declare function chmodIfExists(filePath: string, mode: number): Promise<void>;
23
24
  export declare function normalizeSyncConfig(config: SyncConfig): SyncConfig;
24
25
  export declare function canCommitMcpSecrets(config: SyncConfig): boolean;
25
26
  export declare function loadSyncConfig(locations: SyncLocations): Promise<SyncConfig | null>;
@@ -9,6 +9,17 @@ export async function pathExists(filePath) {
9
9
  return false;
10
10
  }
11
11
  }
12
+ export async function chmodIfExists(filePath, mode) {
13
+ try {
14
+ await fs.chmod(filePath, mode);
15
+ }
16
+ catch (error) {
17
+ const maybeErrno = error;
18
+ if (maybeErrno.code === 'ENOENT')
19
+ return;
20
+ throw error;
21
+ }
22
+ }
12
23
  export function normalizeSyncConfig(config) {
13
24
  const includeSecrets = Boolean(config.includeSecrets);
14
25
  return {
@@ -177,7 +188,7 @@ export async function writeJsonFile(filePath, data, options = { jsonc: false })
177
188
  const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`;
178
189
  await fs.writeFile(filePath, content, 'utf8');
179
190
  if (options.mode !== undefined) {
180
- await fs.chmod(filePath, options.mode);
191
+ await chmodIfExists(filePath, options.mode);
181
192
  }
182
193
  }
183
194
  export function isPlainObject(value) {
@@ -0,0 +1,17 @@
1
+ export interface SyncLockInfo {
2
+ pid: number;
3
+ startedAt: string;
4
+ hostname: string;
5
+ }
6
+ export type SyncLockResult = {
7
+ acquired: true;
8
+ info: SyncLockInfo;
9
+ release: () => Promise<void>;
10
+ } | {
11
+ acquired: false;
12
+ info: SyncLockInfo | null;
13
+ };
14
+ export declare function tryAcquireSyncLock(lockPath: string): Promise<SyncLockResult>;
15
+ export declare function withSyncLock<T>(lockPath: string, options: {
16
+ onBusy: (info: SyncLockInfo | null) => T | Promise<T>;
17
+ }, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,91 @@
1
+ import { mkdir, open, readFile, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function isProcessAlive(pid) {
5
+ if (!Number.isFinite(pid) || pid <= 0)
6
+ return false;
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ const maybeErrno = error;
13
+ if (maybeErrno.code === 'ESRCH')
14
+ return false;
15
+ return true;
16
+ }
17
+ }
18
+ async function readLockInfo(lockPath) {
19
+ try {
20
+ const content = await readFile(lockPath, 'utf8');
21
+ const parsed = JSON.parse(content);
22
+ if (typeof parsed.pid !== 'number')
23
+ return null;
24
+ if (typeof parsed.startedAt !== 'string')
25
+ return null;
26
+ if (typeof parsed.hostname !== 'string')
27
+ return null;
28
+ return {
29
+ pid: parsed.pid,
30
+ startedAt: parsed.startedAt,
31
+ hostname: parsed.hostname,
32
+ };
33
+ }
34
+ catch (error) {
35
+ const maybeErrno = error;
36
+ if (maybeErrno.code === 'ENOENT')
37
+ return null;
38
+ return null;
39
+ }
40
+ }
41
+ export async function tryAcquireSyncLock(lockPath) {
42
+ const parentDir = path.dirname(lockPath);
43
+ await mkdir(parentDir, { recursive: true });
44
+ const ourInfo = {
45
+ pid: process.pid,
46
+ startedAt: new Date().toISOString(),
47
+ hostname: os.hostname(),
48
+ };
49
+ let attempt = 0;
50
+ while (true) {
51
+ try {
52
+ const handle = await open(lockPath, 'wx');
53
+ await handle.writeFile(`${JSON.stringify(ourInfo, null, 2)}\n`, 'utf8');
54
+ return {
55
+ acquired: true,
56
+ info: ourInfo,
57
+ release: async () => {
58
+ await handle.close().catch(() => {
59
+ // ignore close failures
60
+ });
61
+ await rm(lockPath, { force: true });
62
+ },
63
+ };
64
+ }
65
+ catch (error) {
66
+ const maybeErrno = error;
67
+ if (maybeErrno.code !== 'EEXIST')
68
+ throw error;
69
+ const existing = await readLockInfo(lockPath);
70
+ const shouldBreakLock = existing === null || !isProcessAlive(existing.pid);
71
+ if (attempt === 0 && shouldBreakLock) {
72
+ await rm(lockPath, { force: true });
73
+ attempt += 1;
74
+ continue;
75
+ }
76
+ return { acquired: false, info: existing };
77
+ }
78
+ }
79
+ }
80
+ export async function withSyncLock(lockPath, options, fn) {
81
+ const result = await tryAcquireSyncLock(lockPath);
82
+ if (!result.acquired) {
83
+ return await options.onBusy(result.info);
84
+ }
85
+ try {
86
+ return await fn();
87
+ }
88
+ finally {
89
+ await result.release();
90
+ }
91
+ }
@@ -1,15 +1,38 @@
1
+ import path from 'node:path';
1
2
  import { syncLocalToRepo, syncRepoToLocal } from './apply.js';
2
3
  import { generateCommitMessage } from './commit.js';
3
4
  import { canCommitMcpSecrets, loadOverrides, loadState, loadSyncConfig, normalizeSyncConfig, writeState, writeSyncConfig, } from './config.js';
4
5
  import { SyncCommandError, SyncConfigMissingError } from './errors.js';
6
+ import { withSyncLock } from './lock.js';
5
7
  import { buildSyncPlan, resolveRepoRoot, resolveSyncLocations } from './paths.js';
6
8
  import { commitAll, ensureRepoCloned, ensureRepoPrivate, fetchAndFastForward, findSyncRepo, getAuthenticatedUser, getRepoStatus, hasLocalChanges, isRepoCloned, pushBranch, repoExists, resolveRepoBranch, resolveRepoIdentifier, } from './repo.js';
7
9
  import { createLogger, extractTextFromResponse, resolveSmallModel, showToast, unwrapData, } from './utils.js';
8
10
  export function createSyncService(ctx) {
9
11
  const locations = resolveSyncLocations();
10
12
  const log = createLogger(ctx.client);
13
+ const lockPath = path.join(path.dirname(locations.statePath), 'sync.lock');
14
+ const formatLockInfo = (info) => {
15
+ if (!info)
16
+ return 'Another sync is already in progress.';
17
+ return `Another sync is already in progress (pid ${info.pid} on ${info.hostname}, started ${info.startedAt}).`;
18
+ };
19
+ const runExclusive = (fn) => withSyncLock(lockPath, {
20
+ onBusy: (info) => {
21
+ throw new SyncCommandError(formatLockInfo(info));
22
+ },
23
+ }, fn);
24
+ const skipIfBusy = (fn) => withSyncLock(lockPath, {
25
+ onBusy: (info) => {
26
+ log.debug('Sync already running, skipping', {
27
+ pid: info?.pid,
28
+ hostname: info?.hostname,
29
+ startedAt: info?.startedAt,
30
+ });
31
+ return;
32
+ },
33
+ }, fn);
11
34
  return {
12
- startupSync: async () => {
35
+ startupSync: () => skipIfBusy(async () => {
13
36
  let config = null;
14
37
  try {
15
38
  config = await loadSyncConfig(locations);
@@ -31,7 +54,7 @@ export function createSyncService(ctx) {
31
54
  log.error('Startup sync failed', { error: formatError(error) });
32
55
  await showToast(ctx.client, formatError(error), 'error');
33
56
  }
34
- },
57
+ }),
35
58
  status: async () => {
36
59
  const config = await loadSyncConfig(locations);
37
60
  if (!config) {
@@ -87,7 +110,7 @@ export function createSyncService(ctx) {
87
110
  ];
88
111
  return statusLines.join('\n');
89
112
  },
90
- init: async (options) => {
113
+ init: (options) => runExclusive(async () => {
91
114
  const config = await buildConfigFromInit(ctx.$, options);
92
115
  const repoIdentifier = resolveRepoIdentifier(config);
93
116
  const isPrivate = options.private ?? true;
@@ -123,8 +146,8 @@ export function createSyncService(ctx) {
123
146
  `Local repo: ${repoRoot}`,
124
147
  ];
125
148
  return lines.join('\n');
126
- },
127
- link: async (options) => {
149
+ }),
150
+ link: (options) => runExclusive(async () => {
128
151
  const found = await findSyncRepo(ctx.$, options.repo);
129
152
  if (!found) {
130
153
  const searchedFor = options.repo
@@ -175,8 +198,8 @@ export function createSyncService(ctx) {
175
198
  ];
176
199
  await showToast(ctx.client, 'Config synced. Restart OpenCode to apply.', 'info');
177
200
  return lines.join('\n');
178
- },
179
- pull: async () => {
201
+ }),
202
+ pull: () => runExclusive(async () => {
180
203
  const config = await getConfigOrThrow(locations);
181
204
  const repoRoot = resolveRepoRoot(config, locations);
182
205
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -199,8 +222,8 @@ export function createSyncService(ctx) {
199
222
  });
200
223
  await showToast(ctx.client, 'Config updated. Restart OpenCode to apply.', 'info');
201
224
  return 'Remote config applied. Restart OpenCode to use new settings.';
202
- },
203
- push: async () => {
225
+ }),
226
+ push: () => runExclusive(async () => {
204
227
  const config = await getConfigOrThrow(locations);
205
228
  const repoRoot = resolveRepoRoot(config, locations);
206
229
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -227,8 +250,8 @@ export function createSyncService(ctx) {
227
250
  lastPush: new Date().toISOString(),
228
251
  });
229
252
  return `Pushed changes: ${message}`;
230
- },
231
- enableSecrets: async (options) => {
253
+ }),
254
+ enableSecrets: (options) => runExclusive(async () => {
232
255
  const config = await getConfigOrThrow(locations);
233
256
  config.includeSecrets = true;
234
257
  if (options?.extraSecretPaths) {
@@ -240,8 +263,8 @@ export function createSyncService(ctx) {
240
263
  await ensureRepoPrivate(ctx.$, config);
241
264
  await writeSyncConfig(locations, config);
242
265
  return 'Secrets sync enabled for this repo.';
243
- },
244
- resolve: async () => {
266
+ }),
267
+ resolve: () => runExclusive(async () => {
245
268
  const config = await getConfigOrThrow(locations);
246
269
  const repoRoot = resolveRepoRoot(config, locations);
247
270
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -267,7 +290,7 @@ export function createSyncService(ctx) {
267
290
  }
268
291
  }
269
292
  return `Unable to automatically resolve. Please manually resolve in: ${repoRoot}`;
270
- },
293
+ }),
271
294
  };
272
295
  }
273
296
  async function runStartup(ctx, locations, config, log) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-synced",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sync global OpenCode config across machines via GitHub.",
5
5
  "author": {
6
6
  "name": "Ian Hildebrand"