opencode-synced 0.7.1 → 0.8.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
@@ -77,6 +77,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
77
77
  "includeSessions": false,
78
78
  "includePromptStash": false,
79
79
  "extraSecretPaths": [],
80
+ "extraConfigPaths": [],
80
81
  }
81
82
  ```
82
83
 
@@ -87,6 +88,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
87
88
  - `~/.config/opencode/opencode.json` and `opencode.jsonc`
88
89
  - `~/.config/opencode/AGENTS.md`
89
90
  - `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`
91
+ - Any extra paths in `extraConfigPaths` (allowlist, files or folders)
90
92
 
91
93
  ### Secrets (private repos only)
92
94
 
@@ -94,7 +96,7 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:
94
96
 
95
97
  - `~/.local/share/opencode/auth.json`
96
98
  - `~/.local/share/opencode/mcp-auth.json`
97
- - Any extra paths in `extraSecretPaths` (allowlist)
99
+ - Any extra paths in `extraSecretPaths` (allowlist, files or folders)
98
100
 
99
101
  MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them
100
102
  in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`).
@@ -10,3 +10,4 @@ If the user wants an org-owned repo, pass owner="org-name".
10
10
  If the user wants a public repo, pass private=false.
11
11
  Include includeSecrets if the user explicitly opts in.
12
12
  Include includeMcpSecrets only if they want MCP secrets committed to a private repo.
13
+ If the user supplies extra config paths, pass extraConfigPaths.
package/dist/index.js CHANGED
@@ -115,6 +115,7 @@ export const opencodeConfigSync = async (ctx) => {
115
115
  create: tool.schema.boolean().optional().describe('Create repo if missing'),
116
116
  private: tool.schema.boolean().optional().describe('Create repo as private'),
117
117
  extraSecretPaths: tool.schema.array(tool.schema.string()).optional(),
118
+ extraConfigPaths: tool.schema.array(tool.schema.string()).optional(),
118
119
  localRepoPath: tool.schema.string().optional().describe('Override local repo path'),
119
120
  },
120
121
  async execute(args) {
@@ -136,6 +137,7 @@ export const opencodeConfigSync = async (ctx) => {
136
137
  create: args.create,
137
138
  private: args.private,
138
139
  extraSecretPaths: args.extraSecretPaths,
140
+ extraConfigPaths: args.extraConfigPaths,
139
141
  localRepoPath: args.localRepoPath,
140
142
  });
141
143
  }
@@ -7,7 +7,8 @@ export async function syncRepoToLocal(plan, overrides) {
7
7
  for (const item of plan.items) {
8
8
  await copyItem(item.repoPath, item.localPath, item.type);
9
9
  }
10
- await applyExtraSecrets(plan, true);
10
+ await applyExtraPaths(plan, plan.extraConfigs);
11
+ await applyExtraPaths(plan, plan.extraSecrets);
11
12
  if (overrides && Object.keys(overrides).length > 0) {
12
13
  await applyOverridesToLocalConfig(plan, overrides);
13
14
  }
@@ -49,7 +50,8 @@ export async function syncLocalToRepo(plan, overrides, options = {}) {
49
50
  }
50
51
  await copyItem(item.localPath, item.repoPath, item.type, true);
51
52
  }
52
- await writeExtraSecretsManifest(plan);
53
+ await writeExtraPathManifest(plan, plan.extraConfigs);
54
+ await writeExtraPathManifest(plan, plan.extraSecrets);
53
55
  }
54
56
  async function copyItem(sourcePath, destinationPath, type, removeWhenMissing = false) {
55
57
  if (!(await pathExists(sourcePath))) {
@@ -139,13 +141,13 @@ async function copyDirRecursive(sourcePath, destinationPath) {
139
141
  async function removePath(targetPath) {
140
142
  await fs.rm(targetPath, { recursive: true, force: true });
141
143
  }
142
- async function applyExtraSecrets(plan, fromRepo) {
143
- const allowlist = plan.extraSecrets.allowlist;
144
+ async function applyExtraPaths(plan, extra) {
145
+ const allowlist = extra.allowlist;
144
146
  if (allowlist.length === 0)
145
147
  return;
146
- if (!(await pathExists(plan.extraSecrets.manifestPath)))
148
+ if (!(await pathExists(extra.manifestPath)))
147
149
  return;
148
- const manifestContent = await fs.readFile(plan.extraSecrets.manifestPath, 'utf8');
150
+ const manifestContent = await fs.readFile(extra.manifestPath, 'utf8');
149
151
  const manifest = parseJsonc(manifestContent);
150
152
  for (const entry of manifest.entries) {
151
153
  const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform);
@@ -156,41 +158,116 @@ async function applyExtraSecrets(plan, fromRepo) {
156
158
  ? entry.repoPath
157
159
  : path.join(plan.repoRoot, entry.repoPath);
158
160
  const localPath = entry.sourcePath;
161
+ const entryType = entry.type ?? 'file';
159
162
  if (!(await pathExists(repoPath)))
160
163
  continue;
161
- if (fromRepo) {
162
- await copyFileWithMode(repoPath, localPath);
163
- if (entry.mode !== undefined) {
164
- await chmodIfExists(localPath, entry.mode);
165
- }
166
- }
164
+ await copyItem(repoPath, localPath, entryType);
165
+ await applyExtraPathModes(localPath, entry);
167
166
  }
168
167
  }
169
- async function writeExtraSecretsManifest(plan) {
170
- const allowlist = plan.extraSecrets.allowlist;
171
- const extraDir = path.join(path.dirname(plan.extraSecrets.manifestPath), 'extra');
168
+ async function writeExtraPathManifest(plan, extra) {
169
+ const allowlist = extra.allowlist;
170
+ const extraDir = path.join(path.dirname(extra.manifestPath), 'extra');
172
171
  if (allowlist.length === 0) {
173
- await removePath(plan.extraSecrets.manifestPath);
172
+ await removePath(extra.manifestPath);
174
173
  await removePath(extraDir);
175
174
  return;
176
175
  }
177
176
  await removePath(extraDir);
178
177
  const entries = [];
179
- for (const entry of plan.extraSecrets.entries) {
178
+ for (const entry of extra.entries) {
180
179
  const sourcePath = entry.sourcePath;
181
180
  if (!(await pathExists(sourcePath))) {
182
181
  continue;
183
182
  }
184
183
  const stat = await fs.stat(sourcePath);
185
- await copyFileWithMode(sourcePath, entry.repoPath);
186
- entries.push({
187
- sourcePath,
188
- repoPath: path.relative(plan.repoRoot, entry.repoPath),
189
- mode: stat.mode & 0o777,
190
- });
184
+ if (stat.isDirectory()) {
185
+ await copyDirRecursive(sourcePath, entry.repoPath);
186
+ const items = await collectExtraPathItems(sourcePath, sourcePath);
187
+ entries.push({
188
+ sourcePath,
189
+ repoPath: path.relative(plan.repoRoot, entry.repoPath),
190
+ type: 'dir',
191
+ mode: stat.mode & 0o777,
192
+ items,
193
+ });
194
+ continue;
195
+ }
196
+ if (stat.isFile()) {
197
+ await copyFileWithMode(sourcePath, entry.repoPath);
198
+ entries.push({
199
+ sourcePath,
200
+ repoPath: path.relative(plan.repoRoot, entry.repoPath),
201
+ type: 'file',
202
+ mode: stat.mode & 0o777,
203
+ });
204
+ }
205
+ }
206
+ await fs.mkdir(path.dirname(extra.manifestPath), { recursive: true });
207
+ await writeJsonFile(extra.manifestPath, { entries }, { jsonc: false });
208
+ }
209
+ async function collectExtraPathItems(sourcePath, basePath) {
210
+ const items = [];
211
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true });
212
+ for (const entry of entries) {
213
+ const entrySource = path.join(sourcePath, entry.name);
214
+ const relativePath = path.relative(basePath, entrySource);
215
+ if (entry.isDirectory()) {
216
+ const stat = await fs.stat(entrySource);
217
+ items.push({
218
+ relativePath,
219
+ type: 'dir',
220
+ mode: stat.mode & 0o777,
221
+ });
222
+ const nested = await collectExtraPathItems(entrySource, basePath);
223
+ items.push(...nested);
224
+ continue;
225
+ }
226
+ if (entry.isFile()) {
227
+ const stat = await fs.stat(entrySource);
228
+ items.push({
229
+ relativePath,
230
+ type: 'file',
231
+ mode: stat.mode & 0o777,
232
+ });
233
+ }
234
+ }
235
+ return items;
236
+ }
237
+ async function applyExtraPathModes(targetPath, entry) {
238
+ if (entry.mode !== undefined) {
239
+ await chmodIfExists(targetPath, entry.mode);
240
+ }
241
+ if (entry.type !== 'dir') {
242
+ return;
243
+ }
244
+ if (!entry.items || entry.items.length === 0) {
245
+ return;
246
+ }
247
+ for (const item of entry.items) {
248
+ if (item.mode === undefined)
249
+ continue;
250
+ const itemPath = resolveExtraPathItem(targetPath, item.relativePath);
251
+ if (!itemPath)
252
+ continue;
253
+ await chmodIfExists(itemPath, item.mode);
254
+ }
255
+ }
256
+ function resolveExtraPathItem(basePath, relativePath) {
257
+ if (!relativePath)
258
+ return null;
259
+ if (path.isAbsolute(relativePath))
260
+ return null;
261
+ const resolvedBase = path.resolve(basePath);
262
+ const resolvedPath = path.resolve(basePath, relativePath);
263
+ const relative = path.relative(resolvedBase, resolvedPath);
264
+ if (relative === '..' || relative.startsWith(`..${path.sep}`)) {
265
+ return null;
266
+ }
267
+ if (path.isAbsolute(relative)) {
268
+ return null;
191
269
  }
192
- await fs.mkdir(path.dirname(plan.extraSecrets.manifestPath), { recursive: true });
193
- await writeJsonFile(plan.extraSecrets.manifestPath, { entries }, { jsonc: false });
270
+ return resolvedPath;
194
271
  }
195
272
  function isDeepEqual(left, right) {
196
273
  if (left === right)
@@ -13,6 +13,7 @@ export interface SyncConfig {
13
13
  includeSessions?: boolean;
14
14
  includePromptStash?: boolean;
15
15
  extraSecretPaths?: string[];
16
+ extraConfigPaths?: string[];
16
17
  }
17
18
  export interface SyncState {
18
19
  lastPull?: string;
@@ -28,6 +28,7 @@ export function normalizeSyncConfig(config) {
28
28
  includeSessions: Boolean(config.includeSessions),
29
29
  includePromptStash: Boolean(config.includePromptStash),
30
30
  extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
31
+ extraConfigPaths: Array.isArray(config.extraConfigPaths) ? config.extraConfigPaths : [],
31
32
  localRepoPath: config.localRepoPath,
32
33
  repo: config.repo,
33
34
  };
@@ -21,7 +21,7 @@ export interface SyncItem {
21
21
  isSecret: boolean;
22
22
  isConfigFile: boolean;
23
23
  }
24
- export interface ExtraSecretPlan {
24
+ export interface ExtraPathPlan {
25
25
  allowlist: string[];
26
26
  manifestPath: string;
27
27
  entries: Array<{
@@ -31,7 +31,8 @@ export interface ExtraSecretPlan {
31
31
  }
32
32
  export interface SyncPlan {
33
33
  items: SyncItem[];
34
- extraSecrets: ExtraSecretPlan;
34
+ extraSecrets: ExtraPathPlan;
35
+ extraConfigs: ExtraPathPlan;
35
36
  repoRoot: string;
36
37
  homeDir: string;
37
38
  platform: NodeJS.Platform;
@@ -42,6 +43,7 @@ export declare function resolveSyncLocations(env?: NodeJS.ProcessEnv, platform?:
42
43
  export declare function expandHome(inputPath: string, homeDir: string): string;
43
44
  export declare function normalizePath(inputPath: string, homeDir: string, platform?: NodeJS.Platform): string;
44
45
  export declare function isSamePath(left: string, right: string, homeDir: string, platform?: NodeJS.Platform): boolean;
45
- export declare function encodeSecretPath(inputPath: string): string;
46
+ export declare function encodeExtraPath(inputPath: string): string;
47
+ export declare const encodeSecretPath: typeof encodeExtraPath;
46
48
  export declare function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string;
47
49
  export declare function buildSyncPlan(config: SyncConfig, locations: SyncLocations, repoRoot: string, platform?: NodeJS.Platform): SyncPlan;
@@ -75,13 +75,14 @@ export function normalizePath(inputPath, homeDir, platform = process.platform) {
75
75
  export function isSamePath(left, right, homeDir, platform = process.platform) {
76
76
  return normalizePath(left, homeDir, platform) === normalizePath(right, homeDir, platform);
77
77
  }
78
- export function encodeSecretPath(inputPath) {
78
+ export function encodeExtraPath(inputPath) {
79
79
  const normalized = inputPath.replace(/\\/g, '/');
80
80
  const safeBase = normalized.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+/, '');
81
81
  const hash = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 8);
82
- const base = safeBase ? safeBase.slice(-80) : 'secret';
82
+ const base = safeBase ? safeBase.slice(-80) : 'path';
83
83
  return `${base}-${hash}`;
84
84
  }
85
+ export const encodeSecretPath = encodeExtraPath;
85
86
  export function resolveRepoRoot(config, locations) {
86
87
  if (config?.localRepoPath) {
87
88
  return expandHome(config.localRepoPath, locations.xdg.homeDir);
@@ -96,6 +97,8 @@ export function buildSyncPlan(config, locations, repoRoot, platform = process.pl
96
97
  const repoSecretsRoot = path.join(repoRoot, 'secrets');
97
98
  const repoExtraDir = path.join(repoSecretsRoot, 'extra');
98
99
  const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json');
100
+ const repoConfigExtraDir = path.join(repoConfigRoot, 'extra');
101
+ const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json');
99
102
  const items = [];
100
103
  const addFile = (name, isSecret, isConfigFile) => {
101
104
  items.push({
@@ -157,22 +160,26 @@ export function buildSyncPlan(config, locations, repoRoot, platform = process.pl
157
160
  }
158
161
  }
159
162
  }
160
- const allowlist = config.includeSecrets
161
- ? (config.extraSecretPaths ?? []).map((entry) => normalizePath(entry, locations.xdg.homeDir, platform))
162
- : [];
163
- const entries = allowlist.map((sourcePath) => ({
164
- sourcePath,
165
- repoPath: path.join(repoExtraDir, encodeSecretPath(sourcePath)),
166
- }));
163
+ const extraSecrets = buildExtraPathPlan(config.includeSecrets ? config.extraSecretPaths : [], locations, repoExtraDir, manifestPath, platform);
164
+ const extraConfigs = buildExtraPathPlan(config.extraConfigPaths, locations, repoConfigExtraDir, configManifestPath, platform);
167
165
  return {
168
166
  items,
169
- extraSecrets: {
170
- allowlist,
171
- manifestPath,
172
- entries,
173
- },
167
+ extraSecrets,
168
+ extraConfigs,
174
169
  repoRoot,
175
170
  homeDir: locations.xdg.homeDir,
176
171
  platform,
177
172
  };
178
173
  }
174
+ function buildExtraPathPlan(inputPaths, locations, repoExtraDir, manifestPath, platform) {
175
+ const allowlist = (inputPaths ?? []).map((entry) => normalizePath(entry, locations.xdg.homeDir, platform));
176
+ const entries = allowlist.map((sourcePath) => ({
177
+ sourcePath,
178
+ repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)),
179
+ }));
180
+ return {
181
+ allowlist,
182
+ manifestPath,
183
+ entries,
184
+ };
185
+ }
@@ -13,6 +13,7 @@ interface InitOptions {
13
13
  create?: boolean;
14
14
  private?: boolean;
15
15
  extraSecretPaths?: string[];
16
+ extraConfigPaths?: string[];
16
17
  localRepoPath?: string;
17
18
  }
18
19
  interface LinkOptions {
@@ -171,6 +171,7 @@ export function createSyncService(ctx) {
171
171
  includeSessions: false,
172
172
  includePromptStash: false,
173
173
  extraSecretPaths: [],
174
+ extraConfigPaths: [],
174
175
  });
175
176
  await writeSyncConfig(locations, config);
176
177
  const repoRoot = resolveRepoRoot(config, locations);
@@ -369,6 +370,7 @@ async function buildConfigFromInit($, options) {
369
370
  includeSessions: options.includeSessions ?? false,
370
371
  includePromptStash: options.includePromptStash ?? false,
371
372
  extraSecretPaths: options.extraSecretPaths ?? [],
373
+ extraConfigPaths: options.extraConfigPaths ?? [],
372
374
  localRepoPath: options.localRepoPath,
373
375
  });
374
376
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-synced",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Sync global opencode config across machines via GitHub.",
5
5
  "author": {
6
6
  "name": "Ian Hildebrand"