diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9de84b3..ccbe24a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,3 +34,41 @@ jobs: mise run lint mise run test mise run build + + WindowsPaths: + runs-on: windows-latest + steps: + - name: Download exact PR source + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + $archive = Join-Path $env:RUNNER_TEMP 'source.tar.gz' + $headers = @{ + Accept = 'application/vnd.github+json' + Authorization = "Bearer $env:GH_TOKEN" + 'X-GitHub-Api-Version' = '2022-11-28' + } + $url = "https://api.github.com/repos/${{ github.repository }}/tarball/$env:HEAD_SHA" + Invoke-WebRequest -Uri $url -Headers $headers -OutFile $archive + + # Windows Git cannot materialize `.mise/tasks/lint:fix` because of the colon. + tar -xzf $archive --strip-components=1 --exclude='*/.mise/*' + if ($LASTEXITCODE -ne 0) { + throw "Could not extract source archive for $env:HEAD_SHA" + } + if (-not (Test-Path 'src/sync/paths.test.ts')) { + throw "Source archive for $env:HEAD_SHA did not contain the path tests" + } + Write-Host "Testing source archive pinned to $env:HEAD_SHA" + + - name: Setup Tooling + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.2 + + - name: Run Windows path tests + run: | + bun install --frozen-lockfile --ignore-scripts + bun test src/sync/paths.test.ts diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 1d55678..00d5985 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -2,7 +2,28 @@ import { describe, expect, it } from 'vitest'; import type { SyncConfig } from './config.js'; import { normalizeSyncConfig } from './config.js'; -import { buildSyncPlan, resolveSyncLocations, resolveXdgPaths } from './paths.js'; +import { + buildSyncPlan, + expandHome, + normalizePath, + resolveHomeDir, + resolveRepoRoot, + resolveSyncLocations, + resolveXdgPaths, +} from './paths.js'; + +describe('resolveHomeDir', () => { + it('uses USERPROFILE as the Windows home', () => { + const env = { + USERPROFILE: 'C:\\Users\\Test', + HOME: 'D:\\legacy-home', + HOMEDRIVE: 'E:', + HOMEPATH: '\\fallback-home', + } as NodeJS.ProcessEnv; + + expect(resolveHomeDir(env, 'win32')).toBe('C:\\Users\\Test'); + }); +}); describe('resolveXdgPaths', () => { it('resolves linux defaults', () => { @@ -21,8 +42,28 @@ describe('resolveXdgPaths', () => { } as NodeJS.ProcessEnv; const paths = resolveXdgPaths(env, 'win32'); - expect(paths.configDir).toBe('C:\\Users\\Test\\AppData\\Roaming'); - expect(paths.dataDir).toBe('C:\\Users\\Test\\AppData\\Local'); + expect(paths).toEqual({ + homeDir: 'C:\\Users\\Test', + configDir: 'C:\\Users\\Test\\.config', + dataDir: 'C:\\Users\\Test\\.local\\share', + stateDir: 'C:\\Users\\Test\\.local\\state', + }); + }); + + it('preserves explicit XDG overrides on Windows', () => { + const env = { + USERPROFILE: 'C:\\Users\\Test', + XDG_CONFIG_HOME: 'D:\\xdg\\config', + XDG_DATA_HOME: 'E:\\xdg\\data', + XDG_STATE_HOME: 'F:\\xdg\\state', + } as NodeJS.ProcessEnv; + + expect(resolveXdgPaths(env, 'win32')).toEqual({ + homeDir: 'C:\\Users\\Test', + configDir: 'D:\\xdg\\config', + dataDir: 'E:\\xdg\\data', + stateDir: 'F:\\xdg\\state', + }); }); }); @@ -38,6 +79,89 @@ describe('resolveSyncLocations', () => { expect(locations.syncConfigPath).toBe('/custom/opencode/opencode-synced.jsonc'); expect(locations.overridesPath).toBe('/custom/opencode/opencode-synced.overrides.jsonc'); }); + + it('uses exact Windows semantics for every modeled OpenCode location', () => { + const env = { + USERPROFILE: 'C:\\Users\\Test', + APPDATA: 'C:\\Users\\Test\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\Test\\AppData\\Local', + } as NodeJS.ProcessEnv; + + const locations = resolveSyncLocations(env, 'win32'); + + expect(locations).toEqual({ + xdg: { + homeDir: 'C:\\Users\\Test', + configDir: 'C:\\Users\\Test\\.config', + dataDir: 'C:\\Users\\Test\\.local\\share', + stateDir: 'C:\\Users\\Test\\.local\\state', + }, + configRoot: 'C:\\Users\\Test\\.config\\opencode', + syncConfigPath: 'C:\\Users\\Test\\.config\\opencode\\opencode-synced.jsonc', + overridesPath: 'C:\\Users\\Test\\.config\\opencode\\opencode-synced.overrides.jsonc', + statePath: 'C:\\Users\\Test\\.local\\share\\opencode\\sync-state.json', + defaultRepoDir: 'C:\\Users\\Test\\.local\\share\\opencode\\opencode-synced\\repo', + }); + }); +}); + +describe('Windows path semantics', () => { + const env = { USERPROFILE: 'C:\\Users\\Test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'win32'); + + it('expands and normalizes Windows paths without host-platform leakage', () => { + expect(expandHome('~/shared/config.json', locations.xdg.homeDir, 'win32')).toBe( + 'C:\\Users\\Test\\shared\\config.json' + ); + expect(normalizePath('C:\\Users\\Test\\.CONFIG\\OpenCode', '', 'win32')).toBe( + 'c:\\users\\test\\.config\\opencode' + ); + expect( + resolveRepoRoot( + { repo: { owner: 'acme', name: 'config' }, localRepoPath: '~/sync-repo' }, + locations, + 'win32' + ) + ).toBe('C:\\Users\\Test\\sync-repo'); + }); + + it('builds config, data, state, and repository paths with Windows separators', () => { + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + includeSessions: true, + includePromptStash: true, + extraSecretPaths: ['C:\\Users\\Test\\private\\token.json'], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, 'C:\\sync-repo', 'win32'); + + expect(plan.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + localPath: 'C:\\Users\\Test\\.config\\opencode\\opencode.json', + repoPath: 'C:\\sync-repo\\config\\opencode.json', + }), + expect.objectContaining({ + localPath: 'C:\\Users\\Test\\.local\\share\\opencode\\auth.json', + repoPath: 'C:\\sync-repo\\data\\auth.json', + }), + expect.objectContaining({ + localPath: 'C:\\Users\\Test\\.local\\share\\opencode\\opencode.db', + repoPath: 'C:\\sync-repo\\data\\opencode.db', + }), + expect.objectContaining({ + localPath: 'C:\\Users\\Test\\.local\\state\\opencode\\model.json', + repoPath: 'C:\\sync-repo\\state\\model.json', + }), + ]) + ); + expect(plan.extraSecrets.allowlist).toEqual(['c:\\users\\test\\private\\token.json']); + expect(plan.extraSecrets.manifestPath).toBe('C:\\sync-repo\\secrets\\extra-manifest.json'); + expect( + plan.extraSecrets.entries[0]?.repoPath.startsWith('C:\\sync-repo\\secrets\\extra\\') + ).toBe(true); + }); }); describe('buildSyncPlan', () => { diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 9597267..73f97d0 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -72,12 +72,20 @@ const MODEL_FAVORITES_FILE = 'model.json'; const SKILLS_DIR = 'skills'; const HOME_AGENTS_DIR = '.agents'; +function pathApiFor(platform: NodeJS.Platform): typeof path.posix { + return platform === 'win32' ? path.win32 : path.posix; +} + export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): string { if (platform === 'win32') { - return env.USERPROFILE ?? env.HOMEDRIVE ?? env.HOME ?? ''; + if (env.USERPROFILE) return env.USERPROFILE; + if (env.HOMEDRIVE && env.HOMEPATH) { + return path.win32.join(env.HOMEDRIVE, env.HOMEPATH); + } + return env.HOME ?? ''; } return env.HOME ?? ''; @@ -88,6 +96,7 @@ export function resolveXdgPaths( platform: NodeJS.Platform = process.platform ): XdgPaths { const homeDir = resolveHomeDir(env, platform); + const pathApi = pathApiFor(platform); if (!homeDir) { return { @@ -98,17 +107,9 @@ export function resolveXdgPaths( }; } - if (platform === 'win32') { - const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming'); - const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA - const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); - return { homeDir, configDir, dataDir, stateDir }; - } - - const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config'); - const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share'); - const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state'); + const configDir = env.XDG_CONFIG_HOME ?? pathApi.join(homeDir, '.config'); + const dataDir = env.XDG_DATA_HOME ?? pathApi.join(homeDir, '.local', 'share'); + const stateDir = env.XDG_STATE_HOME ?? pathApi.join(homeDir, '.local', 'state'); return { homeDir, configDir, dataDir, stateDir }; } @@ -118,27 +119,32 @@ export function resolveSyncLocations( platform: NodeJS.Platform = process.platform ): SyncLocations { const xdg = resolveXdgPaths(env, platform); + const pathApi = pathApiFor(platform); const customConfigDir = env.opencode_config_dir; const configRoot = customConfigDir - ? path.resolve(expandHome(customConfigDir, xdg.homeDir)) - : path.join(xdg.configDir, 'opencode'); - const dataRoot = path.join(xdg.dataDir, 'opencode'); + ? pathApi.resolve(expandHome(customConfigDir, xdg.homeDir, platform)) + : pathApi.join(xdg.configDir, 'opencode'); + const dataRoot = pathApi.join(xdg.dataDir, 'opencode'); return { xdg, configRoot, - syncConfigPath: path.join(configRoot, DEFAULT_SYNC_CONFIG_NAME), - overridesPath: path.join(configRoot, DEFAULT_OVERRIDES_NAME), - statePath: path.join(dataRoot, DEFAULT_STATE_NAME), - defaultRepoDir: path.join(dataRoot, 'opencode-synced', 'repo'), + syncConfigPath: pathApi.join(configRoot, DEFAULT_SYNC_CONFIG_NAME), + overridesPath: pathApi.join(configRoot, DEFAULT_OVERRIDES_NAME), + statePath: pathApi.join(dataRoot, DEFAULT_STATE_NAME), + defaultRepoDir: pathApi.join(dataRoot, 'opencode-synced', 'repo'), }; } -export function expandHome(inputPath: string, homeDir: string): string { +export function expandHome( + inputPath: string, + homeDir: string, + platform: NodeJS.Platform = process.platform +): string { if (!inputPath) return inputPath; if (!homeDir) return inputPath; if (inputPath === '~') return homeDir; - if (inputPath.startsWith('~/')) return path.join(homeDir, inputPath.slice(2)); + if (inputPath.startsWith('~/')) return pathApiFor(platform).join(homeDir, inputPath.slice(2)); return inputPath; } @@ -147,8 +153,9 @@ export function normalizePath( homeDir: string, platform: NodeJS.Platform = process.platform ): string { - const expanded = expandHome(inputPath, homeDir); - const resolved = path.resolve(expanded); + const pathApi = pathApiFor(platform); + const expanded = expandHome(inputPath, homeDir, platform); + const resolved = pathApi.resolve(expanded); if (platform === 'win32') { return resolved.toLowerCase(); } @@ -174,9 +181,13 @@ export function encodeExtraPath(inputPath: string): string { export const encodeSecretPath = encodeExtraPath; -export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string { +export function resolveRepoRoot( + config: SyncConfig | null, + locations: SyncLocations, + platform: NodeJS.Platform = process.platform +): string { if (config?.localRepoPath) { - return expandHome(config.localRepoPath, locations.xdg.homeDir); + return expandHome(config.localRepoPath, locations.xdg.homeDir, platform); } return locations.defaultRepoDir; @@ -188,27 +199,28 @@ export function buildSyncPlan( repoRoot: string, platform: NodeJS.Platform = process.platform ): SyncPlan { + const pathApi = pathApiFor(platform); const configRoot = locations.configRoot; - const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); - const stateRoot = path.join(locations.xdg.stateDir, 'opencode'); - const repoConfigRoot = path.join(repoRoot, 'config'); - const repoDataRoot = path.join(repoRoot, 'data'); - const repoSecretsRoot = path.join(repoRoot, 'secrets'); - const repoStateRoot = path.join(repoRoot, 'state'); - const repoExtraDir = path.join(repoSecretsRoot, 'extra'); - const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json'); - const repoConfigExtraDir = path.join(repoConfigRoot, 'extra'); - const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); + const dataRoot = pathApi.join(locations.xdg.dataDir, 'opencode'); + const stateRoot = pathApi.join(locations.xdg.stateDir, 'opencode'); + const repoConfigRoot = pathApi.join(repoRoot, 'config'); + const repoDataRoot = pathApi.join(repoRoot, 'data'); + const repoSecretsRoot = pathApi.join(repoRoot, 'secrets'); + const repoStateRoot = pathApi.join(repoRoot, 'state'); + const repoExtraDir = pathApi.join(repoSecretsRoot, 'extra'); + const manifestPath = pathApi.join(repoSecretsRoot, 'extra-manifest.json'); + const repoConfigExtraDir = pathApi.join(repoConfigRoot, 'extra'); + const configManifestPath = pathApi.join(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; const usingSecretsBackend = hasSecretsBackend(config); - const authJsonPath = path.join(dataRoot, 'auth.json'); - const mcpAuthJsonPath = path.join(dataRoot, 'mcp-auth.json'); + const authJsonPath = pathApi.join(dataRoot, 'auth.json'); + const mcpAuthJsonPath = pathApi.join(dataRoot, 'mcp-auth.json'); const addFile = (name: string, isSecret: boolean, isConfigFile: boolean): void => { items.push({ - localPath: path.join(configRoot, name), - repoPath: path.join(repoConfigRoot, name), + localPath: pathApi.join(configRoot, name), + repoPath: pathApi.join(repoConfigRoot, name), type: 'file', isSecret, isConfigFile, @@ -222,8 +234,8 @@ export function buildSyncPlan( for (const dirName of CONFIG_DIRS) { items.push({ - localPath: path.join(configRoot, dirName), - repoPath: path.join(repoConfigRoot, dirName), + localPath: pathApi.join(configRoot, dirName), + repoPath: pathApi.join(repoConfigRoot, dirName), type: 'dir', isSecret: false, isConfigFile: false, @@ -232,8 +244,8 @@ export function buildSyncPlan( if (config.includeOpencodeSkills !== false) { items.push({ - localPath: path.join(configRoot, SKILLS_DIR), - repoPath: path.join(repoConfigRoot, SKILLS_DIR), + localPath: pathApi.join(configRoot, SKILLS_DIR), + repoPath: pathApi.join(repoConfigRoot, SKILLS_DIR), type: 'dir', isSecret: false, isConfigFile: false, @@ -242,8 +254,8 @@ export function buildSyncPlan( if (config.includeAgentsDir !== false) { items.push({ - localPath: path.join(locations.xdg.homeDir, HOME_AGENTS_DIR), - repoPath: path.join(repoConfigRoot, HOME_AGENTS_DIR), + localPath: pathApi.join(locations.xdg.homeDir, HOME_AGENTS_DIR), + repoPath: pathApi.join(repoConfigRoot, HOME_AGENTS_DIR), type: 'dir', isSecret: false, isConfigFile: false, @@ -252,8 +264,8 @@ export function buildSyncPlan( if (config.includeModelFavorites !== false) { items.push({ - localPath: path.join(stateRoot, MODEL_FAVORITES_FILE), - repoPath: path.join(repoStateRoot, MODEL_FAVORITES_FILE), + localPath: pathApi.join(stateRoot, MODEL_FAVORITES_FILE), + repoPath: pathApi.join(repoStateRoot, MODEL_FAVORITES_FILE), type: 'file', isSecret: false, isConfigFile: false, @@ -265,14 +277,14 @@ export function buildSyncPlan( items.push( { localPath: authJsonPath, - repoPath: path.join(repoDataRoot, 'auth.json'), + repoPath: pathApi.join(repoDataRoot, 'auth.json'), type: 'file', isSecret: true, isConfigFile: false, }, { localPath: mcpAuthJsonPath, - repoPath: path.join(repoDataRoot, 'mcp-auth.json'), + repoPath: pathApi.join(repoDataRoot, 'mcp-auth.json'), type: 'file', isSecret: true, isConfigFile: false, @@ -282,8 +294,8 @@ export function buildSyncPlan( if (config.includeSessions && !isTursoSessionBackend(config)) { items.push({ - localPath: path.join(dataRoot, SESSION_DB_FILE), - repoPath: path.join(repoDataRoot, SESSION_DB_FILE), + localPath: pathApi.join(dataRoot, SESSION_DB_FILE), + repoPath: pathApi.join(repoDataRoot, SESSION_DB_FILE), type: 'file', isSecret: true, isConfigFile: false, @@ -292,8 +304,8 @@ export function buildSyncPlan( for (const dirName of SESSION_DIRS) { items.push({ - localPath: path.join(dataRoot, dirName), - repoPath: path.join(repoDataRoot, dirName), + localPath: pathApi.join(dataRoot, dirName), + repoPath: pathApi.join(repoDataRoot, dirName), type: 'dir', isSecret: true, isConfigFile: false, @@ -305,8 +317,8 @@ export function buildSyncPlan( if (config.includePromptStash) { for (const fileName of PROMPT_STASH_FILES) { items.push({ - localPath: path.join(stateRoot, fileName), - repoPath: path.join(repoStateRoot, fileName), + localPath: pathApi.join(stateRoot, fileName), + repoPath: pathApi.join(repoStateRoot, fileName), type: 'file', isSecret: true, isConfigFile: false, @@ -368,7 +380,7 @@ function buildExtraPathPlan( const entries = allowlist.map((sourcePath) => ({ sourcePath, - repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), + repoPath: pathApiFor(platform).join(repoExtraDir, encodeExtraPath(sourcePath)), })); return {