opencode-synced 0.7.1 → 0.9.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
@@ -76,7 +76,9 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
76
76
  "includeMcpSecrets": false,
77
77
  "includeSessions": false,
78
78
  "includePromptStash": false,
79
+ "includeModelFavorites": true,
79
80
  "extraSecretPaths": [],
81
+ "extraConfigPaths": [],
80
82
  }
81
83
  ```
82
84
 
@@ -87,6 +89,8 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
87
89
  - `~/.config/opencode/opencode.json` and `opencode.jsonc`
88
90
  - `~/.config/opencode/AGENTS.md`
89
91
  - `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`
92
+ - `~/.local/state/opencode/model.json` (model favorites)
93
+ - Any extra paths in `extraConfigPaths` (allowlist, files or folders)
90
94
 
91
95
  ### Secrets (private repos only)
92
96
 
@@ -94,7 +98,7 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:
94
98
 
95
99
  - `~/.local/share/opencode/auth.json`
96
100
  - `~/.local/share/opencode/mcp-auth.json`
97
- - Any extra paths in `extraSecretPaths` (allowlist)
101
+ - Any extra paths in `extraSecretPaths` (allowlist, files or folders)
98
102
 
99
103
  MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them
100
104
  in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`).
@@ -10,3 +10,5 @@ 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.
14
+ Model favorites sync is enabled by default; set includeModelFavorites=false to disable.
package/dist/index.js CHANGED
@@ -112,9 +112,14 @@ export const opencodeConfigSync = async (ctx) => {
112
112
  .boolean()
113
113
  .optional()
114
114
  .describe('Enable prompt stash/history sync (requires includeSecrets)'),
115
+ includeModelFavorites: tool.schema
116
+ .boolean()
117
+ .optional()
118
+ .describe('Sync model favorites (state/model.json)'),
115
119
  create: tool.schema.boolean().optional().describe('Create repo if missing'),
116
120
  private: tool.schema.boolean().optional().describe('Create repo as private'),
117
121
  extraSecretPaths: tool.schema.array(tool.schema.string()).optional(),
122
+ extraConfigPaths: tool.schema.array(tool.schema.string()).optional(),
118
123
  localRepoPath: tool.schema.string().optional().describe('Override local repo path'),
119
124
  },
120
125
  async execute(args) {
@@ -133,9 +138,11 @@ export const opencodeConfigSync = async (ctx) => {
133
138
  includeMcpSecrets: args.includeMcpSecrets,
134
139
  includeSessions: args.includeSessions,
135
140
  includePromptStash: args.includePromptStash,
141
+ includeModelFavorites: args.includeModelFavorites,
136
142
  create: args.create,
137
143
  private: args.private,
138
144
  extraSecretPaths: args.extraSecretPaths,
145
+ extraConfigPaths: args.extraConfigPaths,
139
146
  localRepoPath: args.localRepoPath,
140
147
  });
141
148
  }
@@ -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)
@@ -12,7 +12,9 @@ export interface SyncConfig {
12
12
  includeMcpSecrets?: boolean;
13
13
  includeSessions?: boolean;
14
14
  includePromptStash?: boolean;
15
+ includeModelFavorites?: boolean;
15
16
  extraSecretPaths?: string[];
17
+ extraConfigPaths?: string[];
16
18
  }
17
19
  export interface SyncState {
18
20
  lastPull?: string;
@@ -22,12 +22,15 @@ export async function chmodIfExists(filePath, mode) {
22
22
  }
23
23
  export function normalizeSyncConfig(config) {
24
24
  const includeSecrets = Boolean(config.includeSecrets);
25
+ const includeModelFavorites = config.includeModelFavorites !== false;
25
26
  return {
26
27
  includeSecrets,
27
28
  includeMcpSecrets: includeSecrets ? Boolean(config.includeMcpSecrets) : false,
28
29
  includeSessions: Boolean(config.includeSessions),
29
30
  includePromptStash: Boolean(config.includePromptStash),
31
+ includeModelFavorites,
30
32
  extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
33
+ extraConfigPaths: Array.isArray(config.extraConfigPaths) ? config.extraConfigPaths : [],
31
34
  localRepoPath: config.localRepoPath,
32
35
  repo: config.repo,
33
36
  };
@@ -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;
@@ -9,6 +9,7 @@ const DEFAULT_STATE_NAME = 'sync-state.json';
9
9
  const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin'];
10
10
  const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff'];
11
11
  const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl'];
12
+ const MODEL_FAVORITES_FILE = 'model.json';
12
13
  export function resolveHomeDir(env = process.env, platform = process.platform) {
13
14
  if (platform === 'win32') {
14
15
  return env.USERPROFILE ?? env.HOMEDRIVE ?? env.HOME ?? '';
@@ -75,13 +76,14 @@ export function normalizePath(inputPath, homeDir, platform = process.platform) {
75
76
  export function isSamePath(left, right, homeDir, platform = process.platform) {
76
77
  return normalizePath(left, homeDir, platform) === normalizePath(right, homeDir, platform);
77
78
  }
78
- export function encodeSecretPath(inputPath) {
79
+ export function encodeExtraPath(inputPath) {
79
80
  const normalized = inputPath.replace(/\\/g, '/');
80
81
  const safeBase = normalized.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+/, '');
81
82
  const hash = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 8);
82
- const base = safeBase ? safeBase.slice(-80) : 'secret';
83
+ const base = safeBase ? safeBase.slice(-80) : 'path';
83
84
  return `${base}-${hash}`;
84
85
  }
86
+ export const encodeSecretPath = encodeExtraPath;
85
87
  export function resolveRepoRoot(config, locations) {
86
88
  if (config?.localRepoPath) {
87
89
  return expandHome(config.localRepoPath, locations.xdg.homeDir);
@@ -91,11 +93,15 @@ export function resolveRepoRoot(config, locations) {
91
93
  export function buildSyncPlan(config, locations, repoRoot, platform = process.platform) {
92
94
  const configRoot = locations.configRoot;
93
95
  const dataRoot = path.join(locations.xdg.dataDir, 'opencode');
96
+ const stateRoot = path.join(locations.xdg.stateDir, 'opencode');
94
97
  const repoConfigRoot = path.join(repoRoot, 'config');
95
98
  const repoDataRoot = path.join(repoRoot, 'data');
96
99
  const repoSecretsRoot = path.join(repoRoot, 'secrets');
100
+ const repoStateRoot = path.join(repoRoot, 'state');
97
101
  const repoExtraDir = path.join(repoSecretsRoot, 'extra');
98
102
  const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json');
103
+ const repoConfigExtraDir = path.join(repoConfigRoot, 'extra');
104
+ const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json');
99
105
  const items = [];
100
106
  const addFile = (name, isSecret, isConfigFile) => {
101
107
  items.push({
@@ -118,6 +124,15 @@ export function buildSyncPlan(config, locations, repoRoot, platform = process.pl
118
124
  isConfigFile: false,
119
125
  });
120
126
  }
127
+ if (config.includeModelFavorites !== false) {
128
+ items.push({
129
+ localPath: path.join(stateRoot, MODEL_FAVORITES_FILE),
130
+ repoPath: path.join(repoStateRoot, MODEL_FAVORITES_FILE),
131
+ type: 'file',
132
+ isSecret: false,
133
+ isConfigFile: false,
134
+ });
135
+ }
121
136
  if (config.includeSecrets) {
122
137
  items.push({
123
138
  localPath: path.join(dataRoot, 'auth.json'),
@@ -144,8 +159,6 @@ export function buildSyncPlan(config, locations, repoRoot, platform = process.pl
144
159
  }
145
160
  }
146
161
  if (config.includePromptStash) {
147
- const stateRoot = path.join(locations.xdg.stateDir, 'opencode');
148
- const repoStateRoot = path.join(repoRoot, 'state');
149
162
  for (const fileName of PROMPT_STASH_FILES) {
150
163
  items.push({
151
164
  localPath: path.join(stateRoot, fileName),
@@ -157,22 +170,26 @@ export function buildSyncPlan(config, locations, repoRoot, platform = process.pl
157
170
  }
158
171
  }
159
172
  }
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
- }));
173
+ const extraSecrets = buildExtraPathPlan(config.includeSecrets ? config.extraSecretPaths : [], locations, repoExtraDir, manifestPath, platform);
174
+ const extraConfigs = buildExtraPathPlan(config.extraConfigPaths, locations, repoConfigExtraDir, configManifestPath, platform);
167
175
  return {
168
176
  items,
169
- extraSecrets: {
170
- allowlist,
171
- manifestPath,
172
- entries,
173
- },
177
+ extraSecrets,
178
+ extraConfigs,
174
179
  repoRoot,
175
180
  homeDir: locations.xdg.homeDir,
176
181
  platform,
177
182
  };
178
183
  }
184
+ function buildExtraPathPlan(inputPaths, locations, repoExtraDir, manifestPath, platform) {
185
+ const allowlist = (inputPaths ?? []).map((entry) => normalizePath(entry, locations.xdg.homeDir, platform));
186
+ const entries = allowlist.map((sourcePath) => ({
187
+ sourcePath,
188
+ repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)),
189
+ }));
190
+ return {
191
+ allowlist,
192
+ manifestPath,
193
+ entries,
194
+ };
195
+ }
@@ -10,9 +10,11 @@ interface InitOptions {
10
10
  includeMcpSecrets?: boolean;
11
11
  includeSessions?: boolean;
12
12
  includePromptStash?: boolean;
13
+ includeModelFavorites?: boolean;
13
14
  create?: boolean;
14
15
  private?: boolean;
15
16
  extraSecretPaths?: string[];
17
+ extraConfigPaths?: string[];
16
18
  localRepoPath?: string;
17
19
  }
18
20
  interface LinkOptions {
@@ -83,6 +83,7 @@ export function createSyncService(ctx) {
83
83
  const includeMcpSecrets = config.includeMcpSecrets ? 'enabled' : 'disabled';
84
84
  const includeSessions = config.includeSessions ? 'enabled' : 'disabled';
85
85
  const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled';
86
+ const includeModelFavorites = config.includeModelFavorites ? 'enabled' : 'disabled';
86
87
  const lastPull = state.lastPull ?? 'never';
87
88
  const lastPush = state.lastPush ?? 'never';
88
89
  let changesLabel = 'clean';
@@ -104,6 +105,7 @@ export function createSyncService(ctx) {
104
105
  `MCP secrets: ${includeMcpSecrets}`,
105
106
  `Sessions: ${includeSessions}`,
106
107
  `Prompt stash: ${includePromptStash}`,
108
+ `Model favorites: ${includeModelFavorites}`,
107
109
  `Last pull: ${lastPull}`,
108
110
  `Last push: ${lastPush}`,
109
111
  `Working tree: ${changesLabel}`,
@@ -171,6 +173,7 @@ export function createSyncService(ctx) {
171
173
  includeSessions: false,
172
174
  includePromptStash: false,
173
175
  extraSecretPaths: [],
176
+ extraConfigPaths: [],
174
177
  });
175
178
  await writeSyncConfig(locations, config);
176
179
  const repoRoot = resolveRepoRoot(config, locations);
@@ -368,7 +371,9 @@ async function buildConfigFromInit($, options) {
368
371
  includeMcpSecrets: options.includeMcpSecrets ?? false,
369
372
  includeSessions: options.includeSessions ?? false,
370
373
  includePromptStash: options.includePromptStash ?? false,
374
+ includeModelFavorites: options.includeModelFavorites ?? true,
371
375
  extraSecretPaths: options.extraSecretPaths ?? [],
376
+ extraConfigPaths: options.extraConfigPaths ?? [],
372
377
  localRepoPath: options.localRepoPath,
373
378
  });
374
379
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-synced",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Sync global opencode config across machines via GitHub.",
5
5
  "author": {
6
6
  "name": "Ian Hildebrand"