diff --git a/.gitignore b/.gitignore index 331672b..3e82476 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ coverage/ .bun.lockb .memory/ opencode-plugin-template/ -opencode-docs-*/ \ No newline at end of file +opencode-docs-*/ +.worktrees/ diff --git a/README.md b/README.md index e296e79..67a22b5 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,12 @@ Create `~/.config/opencode/opencode-synced.jsonc`: - `~/.config/opencode/opencode.json` and `opencode.jsonc` - `~/.config/opencode/AGENTS.md` - `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/` +- `~/.config/opencode/agents/`, `instructions/`, `plugins/`, `skills/`, `superpowers/` - `~/.local/state/opencode/model.json` (model favorites) - Any extra paths in `extraConfigPaths` (allowlist, files or folders) +Extra path manifests store home-relative `~` paths when possible to avoid Linux/macOS/WSL path churn. + ### Secrets (private repos only) Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`: diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts new file mode 100644 index 0000000..9492893 --- /dev/null +++ b/src/sync/apply.test.ts @@ -0,0 +1,166 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { syncRepoToLocal } from './apply.js'; +import type { SyncPlan } from './paths.js'; +import { normalizePath } from './paths.js'; + +describe('syncRepoToLocal extra manifest repoPath validation', () => { + it('rejects absolute repoPath values from manifest', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-')); + try { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const localPath = path.join(homeDir, 'target.txt'); + const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json'); + + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify( + { + entries: [ + { + sourcePath: localPath, + repoPath: '/etc/passwd', + type: 'file', + }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const plan: SyncPlan = { + items: [], + extraConfigs: { + allowlist: [normalizePath(localPath, homeDir, 'linux')], + manifestPath, + entries: [], + }, + extraSecrets: { + allowlist: [], + manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'), + entries: [], + }, + repoRoot, + homeDir, + platform: 'linux', + }; + + await expect(syncRepoToLocal(plan, null)).rejects.toThrow(/absolute paths are not allowed/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects traversal repoPath values from manifest', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-')); + try { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const localPath = path.join(homeDir, 'target.txt'); + const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json'); + + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + JSON.stringify( + { + entries: [ + { + sourcePath: localPath, + repoPath: '../../etc/passwd', + type: 'file', + }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const plan: SyncPlan = { + items: [], + extraConfigs: { + allowlist: [normalizePath(localPath, homeDir, 'linux')], + manifestPath, + entries: [], + }, + extraSecrets: { + allowlist: [], + manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'), + entries: [], + }, + repoRoot, + homeDir, + platform: 'linux', + }; + + await expect(syncRepoToLocal(plan, null)).rejects.toThrow(/path escapes repository root/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('accepts safe relative repoPath values from manifest', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-apply-')); + try { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const localPath = path.join(homeDir, 'target.txt'); + const manifestPath = path.join(repoRoot, 'config', 'extra-manifest.json'); + const repoSourcePath = path.join(repoRoot, 'config', 'extra', 'safe.txt'); + + await mkdir(path.dirname(manifestPath), { recursive: true }); + await mkdir(path.dirname(repoSourcePath), { recursive: true }); + await writeFile(repoSourcePath, 'safe-data\n', 'utf8'); + await writeFile( + manifestPath, + JSON.stringify( + { + entries: [ + { + sourcePath: localPath, + repoPath: 'config/extra/safe.txt', + type: 'file', + }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const plan: SyncPlan = { + items: [], + extraConfigs: { + allowlist: [normalizePath(localPath, homeDir, 'linux')], + manifestPath, + entries: [], + }, + extraSecrets: { + allowlist: [], + manifestPath: path.join(repoRoot, 'secrets', 'extra-manifest.json'), + entries: [], + }, + repoRoot, + homeDir, + platform: 'linux', + }; + + await syncRepoToLocal(plan, null); + + const output = await readFile(localPath, 'utf8'); + expect(output).toBe('safe-data\n'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 98bc65e..e4f1df2 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -17,7 +17,7 @@ import { stripOverrideKeys, } from './mcp-secrets.js'; import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; -import { normalizePath } from './paths.js'; +import { expandHome, normalizePath } from './paths.js'; type ExtraPathType = 'file' | 'dir'; @@ -238,10 +238,8 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise { }); describe('buildSyncPlan', () => { + it('includes modern opencode config directories by default', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + const localPaths = new Set(plan.items.map((item) => item.localPath)); + + expect(localPaths.has('/home/test/.config/opencode/agents')).toBe(true); + expect(localPaths.has('/home/test/.config/opencode/instructions')).toBe(true); + expect(localPaths.has('/home/test/.config/opencode/plugins')).toBe(true); + expect(localPaths.has('/home/test/.config/opencode/skills')).toBe(true); + expect(localPaths.has('/home/test/.config/opencode/superpowers')).toBe(true); + }); + it('excludes secrets when includeSecrets is false', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); @@ -87,6 +105,49 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist.length).toBe(0); }); + it('stores extra config paths as portable home-relative source paths', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['/home/test/.config/opencode/skills'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.entries[0]?.sourcePath).toBe('~/.config/opencode/skills'); + }); + + it('uses stable repo paths for home-relative extras across platforms', () => { + const linuxEnv = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const macEnv = { HOME: '/Users/test' } as NodeJS.ProcessEnv; + const linuxLocations = resolveSyncLocations(linuxEnv, 'linux'); + const macLocations = resolveSyncLocations(macEnv, 'darwin'); + const linuxConfig: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['/home/test/.config/opencode/skills'], + }; + const macConfig: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['/Users/test/.config/opencode/skills'], + }; + + const linuxPlan = buildSyncPlan( + normalizeSyncConfig(linuxConfig), + linuxLocations, + '/repo', + 'linux' + ); + const macPlan = buildSyncPlan(normalizeSyncConfig(macConfig), macLocations, '/repo', 'darwin'); + + expect(linuxPlan.extraConfigs.entries[0]?.repoPath).toBe( + macPlan.extraConfigs.entries[0]?.repoPath + ); + }); + it('includes secrets when includeSecrets is true', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 995118d..1f6285d 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -51,7 +51,19 @@ const DEFAULT_SYNC_CONFIG_NAME = 'opencode-synced.jsonc'; const DEFAULT_OVERRIDES_NAME = 'opencode-synced.overrides.jsonc'; const DEFAULT_STATE_NAME = 'sync-state.json'; -const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin']; +const CONFIG_DIRS = [ + 'agent', + 'command', + 'mode', + 'tool', + 'themes', + 'plugin', + 'agents', + 'instructions', + 'plugins', + 'skills', + 'superpowers', +]; const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff']; const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl']; const MODEL_FAVORITES_FILE = 'model.json'; @@ -156,6 +168,30 @@ export function encodeExtraPath(inputPath: string): string { return `${base}-${hash}`; } +export function toPortableSourcePath( + inputPath: string, + homeDir: string, + platform: NodeJS.Platform = process.platform +): string { + if (!homeDir) { + return inputPath; + } + + const normalizedInput = normalizePath(inputPath, homeDir, platform); + const normalizedHome = normalizePath(homeDir, homeDir, platform); + if (normalizedInput === normalizedHome) { + return '~'; + } + + const relativeToHome = path.relative(normalizedHome, normalizedInput); + const outsideHome = relativeToHome === '..' || relativeToHome.startsWith(`..${path.sep}`); + if (outsideHome || path.isAbsolute(relativeToHome)) { + return normalizedInput; + } + + return `~/${relativeToHome.split(path.sep).join('/')}`; +} + export const encodeSecretPath = encodeExtraPath; export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string { @@ -319,10 +355,13 @@ function buildExtraPathPlan( normalizePath(entry, locations.xdg.homeDir, platform) ); - const entries = allowlist.map((sourcePath) => ({ - sourcePath, - repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), - })); + const entries = allowlist.map((sourcePath) => { + const portableSourcePath = toPortableSourcePath(sourcePath, locations.xdg.homeDir, platform); + return { + sourcePath: portableSourcePath, + repoPath: path.join(repoExtraDir, encodeExtraPath(portableSourcePath)), + }; + }); return { allowlist,