opencode-synced 0.5.1 → 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 {
@@ -106,34 +117,14 @@ export function stripOverrides(localConfig, overrides, baseConfig) {
106
117
  return result;
107
118
  }
108
119
  export function parseJsonc(content) {
109
- const stripped = stripJsonComments(content);
110
- return JSON.parse(stripped);
111
- }
112
- export async function writeJsonFile(filePath, data, options = { jsonc: false }) {
113
- const json = JSON.stringify(data, null, 2);
114
- const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`;
115
- await fs.writeFile(filePath, content, 'utf8');
116
- if (options.mode !== undefined) {
117
- await fs.chmod(filePath, options.mode);
118
- }
119
- }
120
- export function isPlainObject(value) {
121
- if (!value || typeof value !== 'object')
122
- return false;
123
- return Object.getPrototypeOf(value) === Object.prototype;
124
- }
125
- export function hasOwn(target, key) {
126
- return Object.hasOwn(target, key);
127
- }
128
- function stripJsonComments(input) {
129
120
  let output = '';
130
121
  let inString = false;
131
122
  let inSingleLine = false;
132
123
  let inMultiLine = false;
133
124
  let escapeNext = false;
134
- for (let i = 0; i < input.length; i += 1) {
135
- const current = input[i];
136
- const next = input[i + 1];
125
+ for (let i = 0; i < content.length; i += 1) {
126
+ const current = content[i];
127
+ const next = content[i + 1];
137
128
  if (inSingleLine) {
138
129
  if (current === '\n') {
139
130
  inSingleLine = false;
@@ -163,7 +154,7 @@ function stripJsonComments(input) {
163
154
  }
164
155
  continue;
165
156
  }
166
- if (current === '"' && !inString) {
157
+ if (current === '"') {
167
158
  inString = true;
168
159
  output += current;
169
160
  continue;
@@ -178,7 +169,33 @@ function stripJsonComments(input) {
178
169
  i += 1;
179
170
  continue;
180
171
  }
172
+ if (current === ',') {
173
+ let nextIndex = i + 1;
174
+ while (nextIndex < content.length && /\s/.test(content[nextIndex])) {
175
+ nextIndex += 1;
176
+ }
177
+ const nextChar = content[nextIndex];
178
+ if (nextChar === '}' || nextChar === ']') {
179
+ continue;
180
+ }
181
+ }
181
182
  output += current;
182
183
  }
183
- return output;
184
+ return JSON.parse(output);
185
+ }
186
+ export async function writeJsonFile(filePath, data, options = { jsonc: false }) {
187
+ const json = JSON.stringify(data, null, 2);
188
+ const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`;
189
+ await fs.writeFile(filePath, content, 'utf8');
190
+ if (options.mode !== undefined) {
191
+ await chmodIfExists(filePath, options.mode);
192
+ }
193
+ }
194
+ export function isPlainObject(value) {
195
+ if (!value || typeof value !== 'object')
196
+ return false;
197
+ return Object.getPrototypeOf(value) === Object.prototype;
198
+ }
199
+ export function hasOwn(target, key) {
200
+ return Object.hasOwn(target, key);
184
201
  }
@@ -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,16 +1,48 @@
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 () => {
13
- const config = await loadSyncConfig(locations);
35
+ startupSync: () => skipIfBusy(async () => {
36
+ let config = null;
37
+ try {
38
+ config = await loadSyncConfig(locations);
39
+ }
40
+ catch (error) {
41
+ const message = `Failed to load opencode-synced config: ${formatError(error)}`;
42
+ log.error(message, { path: locations.syncConfigPath });
43
+ await showToast(ctx.client, `Failed to load opencode-synced config. Check ${locations.syncConfigPath} for JSON errors.`, 'error');
44
+ return;
45
+ }
14
46
  if (!config) {
15
47
  await showToast(ctx.client, 'Configure opencode-synced with /sync-init or link to an existing repo with /sync-link', 'info');
16
48
  return;
@@ -22,7 +54,7 @@ export function createSyncService(ctx) {
22
54
  log.error('Startup sync failed', { error: formatError(error) });
23
55
  await showToast(ctx.client, formatError(error), 'error');
24
56
  }
25
- },
57
+ }),
26
58
  status: async () => {
27
59
  const config = await loadSyncConfig(locations);
28
60
  if (!config) {
@@ -78,7 +110,7 @@ export function createSyncService(ctx) {
78
110
  ];
79
111
  return statusLines.join('\n');
80
112
  },
81
- init: async (options) => {
113
+ init: (options) => runExclusive(async () => {
82
114
  const config = await buildConfigFromInit(ctx.$, options);
83
115
  const repoIdentifier = resolveRepoIdentifier(config);
84
116
  const isPrivate = options.private ?? true;
@@ -114,8 +146,8 @@ export function createSyncService(ctx) {
114
146
  `Local repo: ${repoRoot}`,
115
147
  ];
116
148
  return lines.join('\n');
117
- },
118
- link: async (options) => {
149
+ }),
150
+ link: (options) => runExclusive(async () => {
119
151
  const found = await findSyncRepo(ctx.$, options.repo);
120
152
  if (!found) {
121
153
  const searchedFor = options.repo
@@ -166,8 +198,8 @@ export function createSyncService(ctx) {
166
198
  ];
167
199
  await showToast(ctx.client, 'Config synced. Restart OpenCode to apply.', 'info');
168
200
  return lines.join('\n');
169
- },
170
- pull: async () => {
201
+ }),
202
+ pull: () => runExclusive(async () => {
171
203
  const config = await getConfigOrThrow(locations);
172
204
  const repoRoot = resolveRepoRoot(config, locations);
173
205
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -190,8 +222,8 @@ export function createSyncService(ctx) {
190
222
  });
191
223
  await showToast(ctx.client, 'Config updated. Restart OpenCode to apply.', 'info');
192
224
  return 'Remote config applied. Restart OpenCode to use new settings.';
193
- },
194
- push: async () => {
225
+ }),
226
+ push: () => runExclusive(async () => {
195
227
  const config = await getConfigOrThrow(locations);
196
228
  const repoRoot = resolveRepoRoot(config, locations);
197
229
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -218,8 +250,8 @@ export function createSyncService(ctx) {
218
250
  lastPush: new Date().toISOString(),
219
251
  });
220
252
  return `Pushed changes: ${message}`;
221
- },
222
- enableSecrets: async (options) => {
253
+ }),
254
+ enableSecrets: (options) => runExclusive(async () => {
223
255
  const config = await getConfigOrThrow(locations);
224
256
  config.includeSecrets = true;
225
257
  if (options?.extraSecretPaths) {
@@ -231,8 +263,8 @@ export function createSyncService(ctx) {
231
263
  await ensureRepoPrivate(ctx.$, config);
232
264
  await writeSyncConfig(locations, config);
233
265
  return 'Secrets sync enabled for this repo.';
234
- },
235
- resolve: async () => {
266
+ }),
267
+ resolve: () => runExclusive(async () => {
236
268
  const config = await getConfigOrThrow(locations);
237
269
  const repoRoot = resolveRepoRoot(config, locations);
238
270
  await ensureRepoCloned(ctx.$, config, repoRoot);
@@ -258,7 +290,7 @@ export function createSyncService(ctx) {
258
290
  }
259
291
  }
260
292
  return `Unable to automatically resolve. Please manually resolve in: ${repoRoot}`;
261
- },
293
+ }),
262
294
  };
263
295
  }
264
296
  async function runStartup(ctx, locations, config, log) {
@@ -23,9 +23,14 @@ function log(client, level, message, extra) {
23
23
  });
24
24
  }
25
25
  export async function showToast(client, message, variant) {
26
- await client.tui.showToast({
27
- body: { title: 'opencode-synced plugin', message, variant },
28
- });
26
+ try {
27
+ await client.tui.showToast({
28
+ body: { title: 'opencode-synced plugin', message, variant },
29
+ });
30
+ }
31
+ catch {
32
+ // Ignore toast failures (e.g. headless mode or early startup).
33
+ }
29
34
  }
30
35
  export function unwrapData(response) {
31
36
  if (!response || typeof response !== 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-synced",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Sync global OpenCode config across machines via GitHub.",
5
5
  "author": {
6
6
  "name": "Ian Hildebrand"