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 +1 -6
- package/dist/sync/apply.js +4 -4
- package/dist/sync/config.d.ts +1 -0
- package/dist/sync/config.js +42 -25
- package/dist/sync/lock.d.ts +17 -0
- package/dist/sync/lock.js +91 -0
- package/dist/sync/service.js +47 -15
- package/dist/sync/utils.js +8 -3
- 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 {
|
|
@@ -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 <
|
|
135
|
-
const current =
|
|
136
|
-
const next =
|
|
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 === '"'
|
|
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
|
+
}
|
package/dist/sync/service.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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) {
|
package/dist/sync/utils.js
CHANGED
|
@@ -23,9 +23,14 @@ function log(client, level, message, extra) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
export async function showToast(client, message, variant) {
|
|
26
|
-
|
|
27
|
-
|
|
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')
|