diff --git a/src/plugin.test.ts b/src/plugin.test.ts index f8efa9fb..edc9ac6b 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -34,6 +34,7 @@ const { getLockFilePath, _installLocalPlugin, _isLocalPluginSource, + _moveDir, _resolveStoredPluginSource, _toLocalPluginSource, } = pluginModule; @@ -735,3 +736,20 @@ describe('plugin source helpers', () => { expect(source).toBe('local:/tmp/plugin'); }); }); + +describe('moveDir', () => { + it('cleans up destination when EXDEV fallback copy fails', () => { + const src = path.join(os.tmpdir(), 'opencli-move-src'); + const dest = path.join(os.tmpdir(), 'opencli-move-dest'); + const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' }); + const copyErr = new Error('copy failed'); + const renameSync = vi.fn(() => { throw renameErr; }); + const cpSync = vi.fn(() => { throw copyErr; }); + const rmSync = vi.fn(() => undefined); + + expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr); + expect(renameSync).toHaveBeenCalledWith(src, dest); + expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true }); + expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true }); + }); +}); diff --git a/src/plugin.ts b/src/plugin.ts index 11b65d32..3397c95b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -88,6 +88,33 @@ function resolveStoredPluginSource(lockEntry: LockEntry | undefined, pluginDir: return lockEntry?.source ?? getPluginSource(pluginDir); } +// ── Filesystem helpers ────────────────────────────────────────────────────── + +/** + * Move a directory, with EXDEV fallback. + * fs.renameSync fails when source and destination are on different + * filesystems (e.g. /tmp → ~/.opencli). In that case we copy then remove. + */ +type MoveDirFsOps = Pick; + +function moveDir(src: string, dest: string, fsOps: MoveDirFsOps = fs): void { + try { + fsOps.renameSync(src, dest); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EXDEV') { + try { + fsOps.cpSync(src, dest, { recursive: true }); + } catch (copyErr) { + try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {} + throw copyErr; + } + fsOps.rmSync(src, { recursive: true, force: true }); + } else { + throw err; + } + } +} + // ── Validation helpers ────────────────────────────────────────────────────── export interface ValidationResult { @@ -292,7 +319,7 @@ function installSinglePlugin( } fs.mkdirSync(PLUGINS_DIR, { recursive: true }); - fs.renameSync(cloneDir, targetDir); + moveDir(cloneDir, targetDir); postInstallLifecycle(targetDir); @@ -404,7 +431,7 @@ function installMonorepo( // Move clone to permanent monorepos location (if not already there) if (!fs.existsSync(repoDir)) { fs.mkdirSync(monoreposDir, { recursive: true }); - fs.renameSync(cloneDir, repoDir); + moveDir(cloneDir, repoDir); } let pluginsToInstall = getEnabledPlugins(manifest); @@ -957,6 +984,7 @@ export { getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, + moveDir as _moveDir, resolveStoredPluginSource as _resolveStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };