diff --git a/docs/issues/skills-path-cross-platform-repair/plan.md b/docs/issues/skills-path-cross-platform-repair/plan.md new file mode 100644 index 000000000..cca17f034 --- /dev/null +++ b/docs/issues/skills-path-cross-platform-repair/plan.md @@ -0,0 +1,25 @@ +# Skills Path Cross-Platform Repair Plan + +## Approach + +Extend `SkillPresenter.resolveSkillsDir()` with a small default-path repair step. The repair only +matches paths that look like DeepChat's default skills location under an OS user home directory: + +- `/Users//.deepchat/skills` +- `:\Users\\.deepchat\skills` + +If matched, return the same suffix under the current `app.getPath('home')` default skills root. +This keeps intentionally custom paths unchanged while covering stale OS/account defaults. + +## Compatibility + +The current malformed path repair for `C:\Users\name.deepchat\skills` is preserved. Existing valid +configured paths continue to resolve through `path.resolve()`. + +## Test Strategy + +Add constructor-level `getSkillsDir()` assertions in `skillPresenter.test.ts` for: + +- POSIX stale default path repair. +- Windows stale default path repair. +- Existing malformed `.deepchat` repair remains unchanged. diff --git a/docs/issues/skills-path-cross-platform-repair/spec.md b/docs/issues/skills-path-cross-platform-repair/spec.md new file mode 100644 index 000000000..59003014a --- /dev/null +++ b/docs/issues/skills-path-cross-platform-repair/spec.md @@ -0,0 +1,32 @@ +# Skills Path Cross-Platform Repair + +## Problem + +DeepChat can fail during startup when the persisted `skillsPath` points to a default skills +directory from another OS or user profile, such as `/Users/old-user/.deepchat/skills` on Windows. +Windows resolves that POSIX-looking path under the current drive, then startup attempts to create a +directory like `C:\Users\old-user\.deepchat\skills` and can fail with `EPERM`. + +## User Story + +As a user who moved configuration between machines or OS accounts, I want DeepChat to recover from +stale default skills paths so the app still opens and uses the current profile's skills directory. + +## Acceptance Criteria + +- Startup repairs stale default skills paths from POSIX `/Users//.deepchat/skills`. +- Startup repairs stale default skills paths from Windows `C:\Users\\.deepchat\skills`. +- Repair keeps any path suffix below `skills`. +- Non-default custom skills paths remain unchanged. +- Existing malformed `.deepchat` path repair keeps working. + +## Non-Goals + +- Do not migrate arbitrary custom directories. +- Do not change skill discovery, installation, or sync behavior. +- Do not add a new settings migration framework. + +## Constraints + +- Keep the change localized to the existing `SkillPresenter` startup path handling. +- Add focused unit coverage for the repaired path patterns. diff --git a/docs/issues/skills-path-cross-platform-repair/tasks.md b/docs/issues/skills-path-cross-platform-repair/tasks.md new file mode 100644 index 000000000..b85b57f63 --- /dev/null +++ b/docs/issues/skills-path-cross-platform-repair/tasks.md @@ -0,0 +1,6 @@ +# Skills Path Cross-Platform Repair Tasks + +- [x] Document the issue and repair scope. +- [x] Extend `SkillPresenter.resolveSkillsDir()` repair logic. +- [x] Add focused unit tests for cross-platform stale default paths. +- [x] Run formatting and the targeted SkillPresenter test. diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index 527427ecd..613759769 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -228,6 +228,13 @@ export class SkillPresenter implements ISkillPresenter { const homeDir = homePath ? path.resolve(homePath) : path.resolve('.') const fallbackDir = path.join(homeDir, '.deepchat', 'skills') const resolved = normalized ? path.resolve(normalized) : fallbackDir + const repairedDefaultPath = normalized + ? this.repairPortableDefaultSkillsPath(normalized, homeDir) + : null + + if (repairedDefaultPath) { + return repairedDefaultPath + } // Repair malformed paths like: C:\Users\name.deepchat\skills const brokenPrefix = `${homeDir}.deepchat` @@ -246,6 +253,20 @@ export class SkillPresenter implements ISkillPresenter { return resolved } + private repairPortableDefaultSkillsPath(configuredPath: string, homeDir: string): string | null { + const slashPath = configuredPath.replace(/\\/g, '/') + const match = + slashPath.match(/^\/Users\/[^/]+\/\.deepchat\/skills(?:\/(.*))?$/i) ?? + slashPath.match(/^[A-Za-z]:\/Users\/[^/]+\/\.deepchat\/skills(?:\/(.*))?$/i) + + if (!match) { + return null + } + + const suffixParts = (match[1] ?? '').split('/').filter(Boolean) + return path.join(homeDir, '.deepchat', 'skills', ...suffixParts) + } + /** * Ensure the skills directory exists */ diff --git a/test/main/presenter/skillPresenter/skillPresenter.test.ts b/test/main/presenter/skillPresenter/skillPresenter.test.ts index f96b2338e..8870e26ca 100644 --- a/test/main/presenter/skillPresenter/skillPresenter.test.ts +++ b/test/main/presenter/skillPresenter/skillPresenter.test.ts @@ -284,11 +284,12 @@ describe('SkillPresenter', () => { expect(fs.existsSync).toHaveBeenCalled() }) - it('should use configured skills path when provided', () => { + it('should use configured skills path when provided', async () => { ;(mockConfigPresenter.getSkillsPath as Mock).mockReturnValue('/custom/skills/path') const presenter = new SkillPresenter(mockConfigPresenter, skillSessionStatePort as any) expect(mockConfigPresenter.getSkillsPath).toHaveBeenCalled() + await expect(presenter.getSkillsDir()).resolves.toBe('/custom/skills/path') presenter.destroy() }) @@ -312,6 +313,36 @@ describe('SkillPresenter', () => { await expect(presenter.getSkillsDir()).resolves.toBe('/mock/home/.deepchat/skills') presenter.destroy() }) + + it('should repair stale POSIX default skills paths from another user profile', async () => { + ;(mockConfigPresenter.getSkillsPath as Mock).mockReturnValue( + '/Users/legacy-user/.deepchat/skills' + ) + ;(app.getPath as Mock).mockImplementation((name: string) => { + if (name === 'home') return '/mock/home' + if (name === 'temp') return '/mock/temp' + return '/mock/' + name + }) + + const presenter = new SkillPresenter(mockConfigPresenter, skillSessionStatePort as any) + await expect(presenter.getSkillsDir()).resolves.toBe('/mock/home/.deepchat/skills') + presenter.destroy() + }) + + it('should repair stale Windows default skills paths from another user profile', async () => { + ;(mockConfigPresenter.getSkillsPath as Mock).mockReturnValue( + 'C:\\Users\\legacy-user\\.deepchat\\skills\\nested' + ) + ;(app.getPath as Mock).mockImplementation((name: string) => { + if (name === 'home') return '/mock/home' + if (name === 'temp') return '/mock/temp' + return '/mock/' + name + }) + + const presenter = new SkillPresenter(mockConfigPresenter, skillSessionStatePort as any) + await expect(presenter.getSkillsDir()).resolves.toBe('/mock/home/.deepchat/skills/nested') + presenter.destroy() + }) }) describe('getSkillsDir', () => {