Skip to content

Commit 5d09e51

Browse files
authored
Merge pull request #29 from iHildy/feat/extra-config-sync
feat: Add support syncing extra config paths
2 parents 13a7fa1 + 659d8bd commit 5d09e51

9 files changed

Lines changed: 200 additions & 51 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
7777
"includeSessions": false,
7878
"includePromptStash": false,
7979
"extraSecretPaths": [],
80+
"extraConfigPaths": [],
8081
}
8182
```
8283

@@ -87,14 +88,15 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
8788
- `~/.config/opencode/opencode.json` and `opencode.jsonc`
8889
- `~/.config/opencode/AGENTS.md`
8990
- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`
91+
- Any extra paths in `extraConfigPaths` (allowlist, files or folders)
9092

9193
### Secrets (private repos only)
9294

9395
Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:
9496

9597
- `~/.local/share/opencode/auth.json`
9698
- `~/.local/share/opencode/mcp-auth.json`
97-
- Any extra paths in `extraSecretPaths` (allowlist)
99+
- Any extra paths in `extraSecretPaths` (allowlist, files or folders)
98100

99101
MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them
100102
in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`).

src/command/sync-init.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ If the user wants an org-owned repo, pass owner="org-name".
1010
If the user wants a public repo, pass private=false.
1111
Include includeSecrets if the user explicitly opts in.
1212
Include includeMcpSecrets only if they want MCP secrets committed to a private repo.
13+
If the user supplies extra config paths, pass extraConfigPaths.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
140140
create: tool.schema.boolean().optional().describe('Create repo if missing'),
141141
private: tool.schema.boolean().optional().describe('Create repo as private'),
142142
extraSecretPaths: tool.schema.array(tool.schema.string()).optional(),
143+
extraConfigPaths: tool.schema.array(tool.schema.string()).optional(),
143144
localRepoPath: tool.schema.string().optional().describe('Override local repo path'),
144145
},
145146
async execute(args) {
@@ -161,6 +162,7 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
161162
create: args.create,
162163
private: args.private,
163164
extraSecretPaths: args.extraSecretPaths,
165+
extraConfigPaths: args.extraConfigPaths,
164166
localRepoPath: args.localRepoPath,
165167
});
166168
}

src/sync/apply.ts

Lines changed: 132 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,27 @@ import {
1616
mergeOverrides,
1717
stripOverrideKeys,
1818
} from './mcp-secrets.js';
19-
import type { SyncItem, SyncPlan } from './paths.js';
19+
import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js';
2020
import { normalizePath } from './paths.js';
2121

22-
interface ExtraSecretManifestEntry {
22+
type ExtraPathType = 'file' | 'dir';
23+
24+
interface ExtraPathManifestItem {
25+
relativePath: string;
26+
type: ExtraPathType;
27+
mode?: number;
28+
}
29+
30+
interface ExtraPathManifestEntry {
2331
sourcePath: string;
2432
repoPath: string;
33+
type?: ExtraPathType;
2534
mode?: number;
35+
items?: ExtraPathManifestItem[];
2636
}
2737

28-
interface ExtraSecretManifest {
29-
entries: ExtraSecretManifestEntry[];
38+
interface ExtraPathManifest {
39+
entries: ExtraPathManifestEntry[];
3040
}
3141

3242
export async function syncRepoToLocal(
@@ -37,7 +47,8 @@ export async function syncRepoToLocal(
3747
await copyItem(item.repoPath, item.localPath, item.type);
3848
}
3949

40-
await applyExtraSecrets(plan, true);
50+
await applyExtraPaths(plan, plan.extraConfigs);
51+
await applyExtraPaths(plan, plan.extraSecrets);
4152

4253
if (overrides && Object.keys(overrides).length > 0) {
4354
await applyOverridesToLocalConfig(plan, overrides);
@@ -90,7 +101,8 @@ export async function syncLocalToRepo(
90101
await copyItem(item.localPath, item.repoPath, item.type, true);
91102
}
92103

93-
await writeExtraSecretsManifest(plan);
104+
await writeExtraPathManifest(plan, plan.extraConfigs);
105+
await writeExtraPathManifest(plan, plan.extraSecrets);
94106
}
95107

96108
async function copyItem(
@@ -212,14 +224,14 @@ async function removePath(targetPath: string): Promise<void> {
212224
await fs.rm(targetPath, { recursive: true, force: true });
213225
}
214226

215-
async function applyExtraSecrets(plan: SyncPlan, fromRepo: boolean): Promise<void> {
216-
const allowlist = plan.extraSecrets.allowlist;
227+
async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise<void> {
228+
const allowlist = extra.allowlist;
217229
if (allowlist.length === 0) return;
218230

219-
if (!(await pathExists(plan.extraSecrets.manifestPath))) return;
231+
if (!(await pathExists(extra.manifestPath))) return;
220232

221-
const manifestContent = await fs.readFile(plan.extraSecrets.manifestPath, 'utf8');
222-
const manifest = parseJsonc<ExtraSecretManifest>(manifestContent);
233+
const manifestContent = await fs.readFile(extra.manifestPath, 'utf8');
234+
const manifest = parseJsonc<ExtraPathManifest>(manifestContent);
223235

224236
for (const entry of manifest.entries) {
225237
const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform);
@@ -230,47 +242,136 @@ async function applyExtraSecrets(plan: SyncPlan, fromRepo: boolean): Promise<voi
230242
? entry.repoPath
231243
: path.join(plan.repoRoot, entry.repoPath);
232244
const localPath = entry.sourcePath;
245+
const entryType: ExtraPathType = entry.type ?? 'file';
233246

234247
if (!(await pathExists(repoPath))) continue;
235248

236-
if (fromRepo) {
237-
await copyFileWithMode(repoPath, localPath);
238-
if (entry.mode !== undefined) {
239-
await chmodIfExists(localPath, entry.mode);
240-
}
241-
}
249+
await copyItem(repoPath, localPath, entryType);
250+
await applyExtraPathModes(localPath, entry);
242251
}
243252
}
244253

245-
async function writeExtraSecretsManifest(plan: SyncPlan): Promise<void> {
246-
const allowlist = plan.extraSecrets.allowlist;
247-
const extraDir = path.join(path.dirname(plan.extraSecrets.manifestPath), 'extra');
254+
async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Promise<void> {
255+
const allowlist = extra.allowlist;
256+
const extraDir = path.join(path.dirname(extra.manifestPath), 'extra');
248257
if (allowlist.length === 0) {
249-
await removePath(plan.extraSecrets.manifestPath);
258+
await removePath(extra.manifestPath);
250259
await removePath(extraDir);
251260
return;
252261
}
253262

254263
await removePath(extraDir);
255264

256-
const entries: ExtraSecretManifestEntry[] = [];
265+
const entries: ExtraPathManifestEntry[] = [];
257266

258-
for (const entry of plan.extraSecrets.entries) {
267+
for (const entry of extra.entries) {
259268
const sourcePath = entry.sourcePath;
260269
if (!(await pathExists(sourcePath))) {
261270
continue;
262271
}
263272
const stat = await fs.stat(sourcePath);
264-
await copyFileWithMode(sourcePath, entry.repoPath);
265-
entries.push({
266-
sourcePath,
267-
repoPath: path.relative(plan.repoRoot, entry.repoPath),
268-
mode: stat.mode & 0o777,
269-
});
273+
if (stat.isDirectory()) {
274+
await copyDirRecursive(sourcePath, entry.repoPath);
275+
const items = await collectExtraPathItems(sourcePath, sourcePath);
276+
entries.push({
277+
sourcePath,
278+
repoPath: path.relative(plan.repoRoot, entry.repoPath),
279+
type: 'dir',
280+
mode: stat.mode & 0o777,
281+
items,
282+
});
283+
continue;
284+
}
285+
if (stat.isFile()) {
286+
await copyFileWithMode(sourcePath, entry.repoPath);
287+
entries.push({
288+
sourcePath,
289+
repoPath: path.relative(plan.repoRoot, entry.repoPath),
290+
type: 'file',
291+
mode: stat.mode & 0o777,
292+
});
293+
}
294+
}
295+
296+
await fs.mkdir(path.dirname(extra.manifestPath), { recursive: true });
297+
await writeJsonFile(extra.manifestPath, { entries }, { jsonc: false });
298+
}
299+
300+
async function collectExtraPathItems(
301+
sourcePath: string,
302+
basePath: string
303+
): Promise<ExtraPathManifestItem[]> {
304+
const items: ExtraPathManifestItem[] = [];
305+
const entries = await fs.readdir(sourcePath, { withFileTypes: true });
306+
307+
for (const entry of entries) {
308+
const entrySource = path.join(sourcePath, entry.name);
309+
const relativePath = path.relative(basePath, entrySource);
310+
311+
if (entry.isDirectory()) {
312+
const stat = await fs.stat(entrySource);
313+
items.push({
314+
relativePath,
315+
type: 'dir',
316+
mode: stat.mode & 0o777,
317+
});
318+
const nested = await collectExtraPathItems(entrySource, basePath);
319+
items.push(...nested);
320+
continue;
321+
}
322+
323+
if (entry.isFile()) {
324+
const stat = await fs.stat(entrySource);
325+
items.push({
326+
relativePath,
327+
type: 'file',
328+
mode: stat.mode & 0o777,
329+
});
330+
}
331+
}
332+
333+
return items;
334+
}
335+
336+
async function applyExtraPathModes(
337+
targetPath: string,
338+
entry: ExtraPathManifestEntry
339+
): Promise<void> {
340+
if (entry.mode !== undefined) {
341+
await chmodIfExists(targetPath, entry.mode);
342+
}
343+
344+
if (entry.type !== 'dir') {
345+
return;
346+
}
347+
348+
if (!entry.items || entry.items.length === 0) {
349+
return;
350+
}
351+
352+
for (const item of entry.items) {
353+
if (item.mode === undefined) continue;
354+
const itemPath = resolveExtraPathItem(targetPath, item.relativePath);
355+
if (!itemPath) continue;
356+
await chmodIfExists(itemPath, item.mode);
357+
}
358+
}
359+
360+
function resolveExtraPathItem(basePath: string, relativePath: string): string | null {
361+
if (!relativePath) return null;
362+
if (path.isAbsolute(relativePath)) return null;
363+
364+
const resolvedBase = path.resolve(basePath);
365+
const resolvedPath = path.resolve(basePath, relativePath);
366+
const relative = path.relative(resolvedBase, resolvedPath);
367+
if (relative === '..' || relative.startsWith(`..${path.sep}`)) {
368+
return null;
369+
}
370+
if (path.isAbsolute(relative)) {
371+
return null;
270372
}
271373

272-
await fs.mkdir(path.dirname(plan.extraSecrets.manifestPath), { recursive: true });
273-
await writeJsonFile(plan.extraSecrets.manifestPath, { entries }, { jsonc: false });
374+
return resolvedPath;
274375
}
275376

276377
function isDeepEqual(left: unknown, right: unknown): boolean {

src/sync/config.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ describe('parseJsonc', () => {
9494
"extraSecretPaths": [
9595
"foo",
9696
],
97+
"extraConfigPaths": [
98+
"bar",
99+
],
97100
}`;
98101

99102
expect(parseJsonc(input)).toEqual({
100103
repo: { owner: 'me', name: 'opencode-config' },
101104
includeSecrets: false,
102105
extraSecretPaths: ['foo'],
106+
extraConfigPaths: ['bar'],
103107
});
104108
});
105109
});

src/sync/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface SyncConfig {
1818
includeSessions?: boolean;
1919
includePromptStash?: boolean;
2020
extraSecretPaths?: string[];
21+
extraConfigPaths?: string[];
2122
}
2223

2324
export interface SyncState {
@@ -53,6 +54,7 @@ export function normalizeSyncConfig(config: SyncConfig): SyncConfig {
5354
includeSessions: Boolean(config.includeSessions),
5455
includePromptStash: Boolean(config.includePromptStash),
5556
extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
57+
extraConfigPaths: Array.isArray(config.extraConfigPaths) ? config.extraConfigPaths : [],
5658
localRepoPath: config.localRepoPath,
5759
repo: config.repo,
5860
};

src/sync/paths.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,15 @@ describe('buildSyncPlan', () => {
4747
repo: { owner: 'acme', name: 'config' },
4848
includeSecrets: false,
4949
extraSecretPaths: ['/home/test/.ssh/id_rsa'],
50+
extraConfigPaths: ['/home/test/.config/opencode/custom.json'],
5051
};
5152

5253
const plan = buildSyncPlan(config, locations, '/repo', 'linux');
5354
const secretItems = plan.items.filter((item) => item.isSecret);
5455

5556
expect(secretItems.length).toBe(0);
5657
expect(plan.extraSecrets.allowlist.length).toBe(0);
58+
expect(plan.extraConfigs.allowlist.length).toBe(1);
5759
});
5860

5961
it('includes secrets when includeSecrets is true', () => {
@@ -63,12 +65,14 @@ describe('buildSyncPlan', () => {
6365
repo: { owner: 'acme', name: 'config' },
6466
includeSecrets: true,
6567
extraSecretPaths: ['/home/test/.ssh/id_rsa'],
68+
extraConfigPaths: ['/home/test/.config/opencode/custom.json'],
6669
};
6770

6871
const plan = buildSyncPlan(config, locations, '/repo', 'linux');
6972
const secretItems = plan.items.filter((item) => item.isSecret);
7073

7174
expect(secretItems.length).toBe(2);
7275
expect(plan.extraSecrets.allowlist.length).toBe(1);
76+
expect(plan.extraConfigs.allowlist.length).toBe(1);
7377
});
7478
});

0 commit comments

Comments
 (0)