diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 98bc65e..4af440b 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'; @@ -241,7 +241,7 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise { expect(plan.extraConfigs.allowlist.length).toBe(1); }); + it('stores extra manifest sourcePath using portable home paths', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + extraSecretPaths: ['/home/test/.ssh/id_rsa'], + extraConfigPaths: [ + '/home/test/.config/opencode/custom.json', + '~/.config/opencode/other.json', + ], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + const sources = plan.extraConfigs.entries.map((e) => e.sourcePath).sort(); + + expect(sources).toEqual(['~/.config/opencode/custom.json', '~/.config/opencode/other.json']); + expect(sources.some((p) => p.includes('/home/test'))).toBe(false); + + // Allowlist remains normalized for matching. + expect(plan.extraConfigs.allowlist).toContain('/home/test/.config/opencode/custom.json'); + expect(plan.extraConfigs.allowlist).toContain('/home/test/.config/opencode/other.json'); + }); + + it('keeps extra manifest sourcePath absolute when outside home dir', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: ['/etc/hosts'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.entries.map((e) => e.sourcePath)).toEqual(['/etc/hosts']); + }); + it('excludes auth files when using 1password backend', () => { 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..8a19577 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -126,6 +126,32 @@ export function expandHome(inputPath: string, homeDir: string): string { return inputPath; } +function collapseHome( + inputPath: string, + homeDir: string, + platform: NodeJS.Platform = process.platform +): string { + if (!inputPath) return inputPath; + if (!homeDir) return inputPath; + + if (inputPath === '~' || inputPath.startsWith('~/')) return inputPath; + + // Best-effort on POSIX platforms. + if (platform === 'win32') return inputPath; + + const resolvedHome = path.resolve(homeDir); + const resolvedPath = path.resolve(inputPath); + + if (resolvedPath === resolvedHome) return '~'; + + const prefix = `${resolvedHome}${path.sep}`; + if (resolvedPath.startsWith(prefix)) { + return `~/${resolvedPath.slice(prefix.length)}`; + } + + return inputPath; +} + export function normalizePath( inputPath: string, homeDir: string, @@ -315,11 +341,20 @@ function buildExtraPathPlan( manifestPath: string, platform: NodeJS.Platform ): ExtraPathPlan { - const allowlist = (inputPaths ?? []).map((entry) => + // Keep sourcePath portable ("~/...") so the manifest and hashed filenames + // are stable across machines/OSes. Use a normalized allowlist for matching. + const rawPaths = (inputPaths ?? []).filter( + (entry) => typeof entry === 'string' && entry.length > 0 + ); + const portablePaths = rawPaths.map((entry) => + collapseHome(entry, locations.xdg.homeDir, platform) + ); + + const allowlist = portablePaths.map((entry) => normalizePath(entry, locations.xdg.homeDir, platform) ); - const entries = allowlist.map((sourcePath) => ({ + const entries = portablePaths.map((sourcePath) => ({ sourcePath, repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), }));