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 +1 -6
- package/dist/sync/apply.js +4 -4
- package/dist/sync/config.d.ts +1 -0
- package/dist/sync/config.js +12 -1
- package/dist/sync/lock.d.ts +17 -0
- package/dist/sync/lock.js +91 -0
- package/dist/sync/service.js +37 -14
- package/package.json +1 -1
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,
|
|
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
|
|
package/dist/sync/apply.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
164
|
+
await chmodIfExists(localPath, entry.mode);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
}
|
package/dist/sync/config.d.ts
CHANGED
|
@@ -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>;
|
package/dist/sync/config.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/sync/service.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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) {
|