diff --git a/package.json b/package.json index 6b5f16c..f1fafa3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "vitest": "^3.2.4" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/command dist/command", + "build": "node scripts/build.mjs", "prepack": "bun run build", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..cb900cf --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,23 @@ +import { cp, rm } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; + +await rm('dist', { force: true, recursive: true }); +await run(process.execPath, ['./node_modules/typescript/bin/tsc', '-p', 'tsconfig.build.json']); +await cp('src/command', 'dist/command', { recursive: true }); + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`)); + }); + }); +} diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 54bd147..028eb7e 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -60,6 +60,16 @@ const MODEL_FAVORITES_FILE = 'model.json'; const SKILLS_DIR = 'skills'; const HOME_AGENTS_DIR = '.agents'; +function pathForPlatform(platform: NodeJS.Platform): path.PlatformPath { + return platform === 'win32' ? path.win32 : path.posix; +} + +export function pathForLocations(locations: SyncLocations): path.PlatformPath { + return locations.xdg.homeDir.includes('\\') || /^[a-zA-Z]:/.test(locations.xdg.homeDir) + ? path.win32 + : path.posix; +} + export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform @@ -87,16 +97,18 @@ export function resolveXdgPaths( } if (platform === 'win32') { - const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming'); - const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); + const pathApi = pathForPlatform(platform); + const configDir = env.APPDATA ?? pathApi.join(homeDir, 'AppData', 'Roaming'); + const dataDir = env.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local'); // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA - const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local'); + const stateDir = env.LOCALAPPDATA ?? pathApi.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 pathApi = pathForPlatform(platform); + 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 }; } @@ -107,26 +119,31 @@ export function resolveSyncLocations( ): SyncLocations { const xdg = resolveXdgPaths(env, platform); const customConfigDir = env.opencode_config_dir; + const pathApi = pathForPlatform(platform); 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('~/') || inputPath.startsWith('~\\')) return pathForPlatform(platform).join(homeDir, inputPath.slice(2)); return inputPath; } @@ -135,8 +152,9 @@ export function normalizePath( homeDir: string, platform: NodeJS.Platform = process.platform ): string { - const expanded = expandHome(inputPath, homeDir); - const resolved = path.resolve(expanded); + const pathApi = pathForPlatform(platform); + const expanded = expandHome(inputPath, homeDir, platform); + const resolved = pathApi.resolve(expanded); if (platform === 'win32') { return resolved.toLowerCase(); } @@ -177,26 +195,27 @@ export function buildSyncPlan( platform: NodeJS.Platform = process.platform ): SyncPlan { 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 pathApi = pathForPlatform(platform); + 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, @@ -210,8 +229,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, @@ -220,8 +239,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, @@ -230,8 +249,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, @@ -240,8 +259,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, @@ -253,14 +272,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, @@ -270,8 +289,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, @@ -280,8 +299,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, @@ -293,8 +312,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, @@ -356,7 +375,7 @@ function buildExtraPathPlan( const entries = allowlist.map((sourcePath) => ({ sourcePath, - repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)), + repoPath: pathForPlatform(platform).join(repoExtraDir, encodeExtraPath(sourcePath)), })); return { diff --git a/src/sync/turso.ts b/src/sync/turso.ts index 678b59f..c3970ce 100644 --- a/src/sync/turso.ts +++ b/src/sync/turso.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import type { NormalizedSyncConfig } from './config.js'; import { isPlainObject, isTursoSessionBackend, pathExists, writeJsonFile } from './config.js'; import { SyncCommandError } from './errors.js'; -import type { SyncLocations } from './paths.js'; +import { pathForLocations, type SyncLocations } from './paths.js'; const SESSION_SYNC_TABLE = 'opencode_session_sync_snapshot'; const CREDENTIAL_VERSION = 1; @@ -560,7 +560,12 @@ export function createTursoSessionBackend(options: { } export function resolveTursoCredentialPath(locations: SyncLocations): string { - return path.join(locations.xdg.dataDir, 'opencode', 'opencode-synced', 'turso-session.json'); + return pathForLocations(locations).join( + locations.xdg.dataDir, + 'opencode', + 'opencode-synced', + 'turso-session.json' + ); } export function resolveSessionDbPaths(locations: SyncLocations): { @@ -568,8 +573,11 @@ export function resolveSessionDbPaths(locations: SyncLocations): { walPath: string; shmPath: string; } { - const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); - const dbPath = path.join(dataRoot, 'opencode.db'); + const dbPath = pathForLocations(locations).join( + locations.xdg.dataDir, + 'opencode', + 'opencode.db' + ); return { dbPath, walPath: `${dbPath}-wal`,