Skip to content

Commit 7a3fb2f

Browse files
Add auto-migration for legacy macOS config path (#36)
* Add auto-migration for legacy macOS config path On macOS, earlier versions stored config at ~/.config/productive-cli/ but the correct macOS-native path is ~/Library/Application Support/. ConfigStore now auto-migrates on first access: merges legacy values (current config takes precedence), removes the legacy file, and prints a one-time notice in the CLI. Migration only triggers on macOS when XDG_CONFIG_HOME is not set. Co-authored-by: Claude <claude@anthropic.com> * Add test for config migration notice in CLI Co-authored-by: Claude <claude@anthropic.com> * Update changelog with config migration entry Co-authored-by: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
1 parent a620069 commit 7a3fb2f

5 files changed

Lines changed: 332 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **API**: Auto-migrate legacy config from `~/.config/` to `~/Library/Application Support/` on macOS ([8ba9e89], [#36])
13+
- **CLI**: Print one-time notice when config migration occurs ([8ba9e89], [#36])
14+
1015
## [0.9.0] - 2026-02-17
1116

1217
### Added
@@ -95,6 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
95100
- Silence `console.error` in unknown subcommand tests to clean up test output ([#26])
96101

97102
[Unreleased]: https://github.com/studiometa/productive-tools/compare/0.9.0...HEAD
103+
[8ba9e89]: https://github.com/studiometa/productive-tools/commit/8ba9e89
104+
[#36]: https://github.com/studiometa/productive-tools/pull/36
98105
[0.9.0]: https://github.com/studiometa/productive-tools/compare/0.8.5...0.9.0
99106
[95c5cbe]: https://github.com/studiometa/productive-tools/commit/95c5cbe
100107
[c22d2da]: https://github.com/studiometa/productive-tools/commit/c22d2da

packages/api/src/utils/config-store.test.ts

Lines changed: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,43 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22

33
vi.mock('node:fs', () => ({
44
readFileSync: vi.fn(),
55
writeFileSync: vi.fn(),
66
mkdirSync: vi.fn(),
77
existsSync: vi.fn(),
8+
unlinkSync: vi.fn(),
9+
rmdirSync: vi.fn(),
810
}));
911

1012
vi.mock('node:os', () => ({
1113
homedir: vi.fn(() => '/home/test'),
1214
}));
1315

14-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
16+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, rmdirSync } from 'node:fs';
1517

1618
import { ConfigStore } from './config-store.js';
1719

20+
let origPlatform: PropertyDescriptor | undefined;
21+
let origXdgConfigHome: string | undefined;
22+
1823
beforeEach(() => {
1924
vi.clearAllMocks();
25+
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
26+
origXdgConfigHome = process.env.XDG_CONFIG_HOME;
27+
delete process.env.XDG_CONFIG_HOME;
28+
// Default to linux so macOS migration doesn't trigger in most tests
29+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
30+
});
31+
32+
afterEach(() => {
33+
if (origPlatform) {
34+
Object.defineProperty(process, 'platform', origPlatform);
35+
}
36+
if (origXdgConfigHome !== undefined) {
37+
process.env.XDG_CONFIG_HOME = origXdgConfigHome;
38+
} else {
39+
delete process.env.XDG_CONFIG_HOME;
40+
}
2041
});
2142

2243
describe('ConfigStore', () => {
@@ -110,13 +131,187 @@ describe('ConfigStore', () => {
110131

111132
describe('platform-specific config dir', () => {
112133
it('uses XDG_CONFIG_HOME when set', () => {
113-
const orig = process.env.XDG_CONFIG_HOME;
114134
process.env.XDG_CONFIG_HOME = '/custom/config';
115135
vi.mocked(readFileSync).mockReturnValue('{}');
116136
const store = new ConfigStore('my-app');
117137
store.get('key');
118138
expect(readFileSync).toHaveBeenCalledWith(expect.stringContaining('/custom/config'), 'utf-8');
119-
process.env.XDG_CONFIG_HOME = orig;
139+
});
140+
});
141+
142+
describe('legacy config migration', () => {
143+
const legacyPath = '/home/test/.config/my-app/config.json';
144+
const nativePath = '/home/test/Library/Application Support/my-app/config.json';
145+
146+
beforeEach(() => {
147+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
148+
});
149+
150+
it('migrates legacy config to macOS-native path', () => {
151+
const legacyData = { organizationId: '30059', userId: '500521' };
152+
153+
vi.mocked(existsSync).mockImplementation((path) => {
154+
if (path === legacyPath) return true;
155+
return true; // ensureConfigDir
156+
});
157+
158+
vi.mocked(readFileSync).mockImplementation((path) => {
159+
if (path === legacyPath) return JSON.stringify(legacyData);
160+
// Current config is empty (file not found or empty)
161+
throw new Error('ENOENT');
162+
});
163+
164+
const store = new ConfigStore('my-app');
165+
166+
// Should have written merged config to native path
167+
expect(writeFileSync).toHaveBeenCalledWith(
168+
nativePath,
169+
JSON.stringify(legacyData, null, 2),
170+
'utf-8',
171+
);
172+
173+
// Should have cleaned up legacy file
174+
expect(unlinkSync).toHaveBeenCalledWith(legacyPath);
175+
expect(rmdirSync).toHaveBeenCalledWith('/home/test/.config/my-app');
176+
177+
// Should report migration
178+
expect(store.didMigrate).toBe(true);
179+
180+
// Should return migrated values
181+
expect(store.get('organizationId')).toBe('30059');
182+
expect(store.get('userId')).toBe('500521');
183+
});
184+
185+
it('merges legacy config with existing native config (native takes precedence)', () => {
186+
const legacyData = { organizationId: '30059', userId: '500521', baseUrl: 'https://old.api' };
187+
const nativeData = { organizationId: '99999' };
188+
189+
vi.mocked(existsSync).mockReturnValue(true);
190+
191+
vi.mocked(readFileSync).mockImplementation((path) => {
192+
if (path === legacyPath) return JSON.stringify(legacyData);
193+
return JSON.stringify(nativeData);
194+
});
195+
196+
const store = new ConfigStore('my-app');
197+
198+
// Native value should win for organizationId
199+
expect(store.get('organizationId')).toBe('99999');
200+
// Legacy values should fill in missing keys
201+
expect(store.get('userId')).toBe('500521');
202+
expect(store.get('baseUrl')).toBe('https://old.api');
203+
expect(store.didMigrate).toBe(true);
204+
});
205+
206+
it('does not migrate when legacy config is empty', () => {
207+
vi.mocked(existsSync).mockImplementation((path) => {
208+
if (path === legacyPath) return true;
209+
return false;
210+
});
211+
212+
vi.mocked(readFileSync).mockImplementation((path) => {
213+
if (path === legacyPath) return JSON.stringify({});
214+
throw new Error('ENOENT');
215+
});
216+
217+
const store = new ConfigStore('my-app');
218+
219+
// Should not have written anything (no save calls from migration)
220+
expect(writeFileSync).not.toHaveBeenCalled();
221+
expect(store.didMigrate).toBe(false);
222+
});
223+
224+
it('does not migrate when legacy file does not exist', () => {
225+
vi.mocked(existsSync).mockReturnValue(false);
226+
vi.mocked(readFileSync).mockImplementation(() => {
227+
throw new Error('ENOENT');
228+
});
229+
230+
const store = new ConfigStore('my-app');
231+
232+
expect(writeFileSync).not.toHaveBeenCalled();
233+
expect(unlinkSync).not.toHaveBeenCalled();
234+
expect(store.didMigrate).toBe(false);
235+
});
236+
237+
it('does not migrate when XDG_CONFIG_HOME is set', () => {
238+
process.env.XDG_CONFIG_HOME = '/custom/config';
239+
240+
vi.mocked(readFileSync).mockReturnValue('{}');
241+
vi.mocked(existsSync).mockReturnValue(false);
242+
243+
const store = new ConfigStore('my-app');
244+
245+
expect(unlinkSync).not.toHaveBeenCalled();
246+
expect(store.didMigrate).toBe(false);
247+
});
248+
249+
it('does not migrate on non-macOS platforms', () => {
250+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
251+
252+
vi.mocked(readFileSync).mockReturnValue('{}');
253+
vi.mocked(existsSync).mockReturnValue(false);
254+
255+
const store = new ConfigStore('my-app');
256+
257+
expect(unlinkSync).not.toHaveBeenCalled();
258+
expect(store.didMigrate).toBe(false);
259+
});
260+
261+
it('does not migrate when native config already has all keys', () => {
262+
const legacyData = { organizationId: '30059' };
263+
const nativeData = { organizationId: '99999' };
264+
265+
vi.mocked(existsSync).mockReturnValue(true);
266+
267+
vi.mocked(readFileSync).mockImplementation((path) => {
268+
if (path === legacyPath) return JSON.stringify(legacyData);
269+
return JSON.stringify(nativeData);
270+
});
271+
272+
const store = new ConfigStore('my-app');
273+
274+
// No new keys to add, so no write from migration
275+
expect(writeFileSync).not.toHaveBeenCalled();
276+
expect(store.didMigrate).toBe(false);
277+
278+
// Legacy file should still be cleaned up
279+
expect(unlinkSync).toHaveBeenCalledWith(legacyPath);
280+
});
281+
282+
it('handles unreadable legacy file gracefully', () => {
283+
vi.mocked(existsSync).mockImplementation((path) => {
284+
if (path === legacyPath) return true;
285+
return false;
286+
});
287+
288+
vi.mocked(readFileSync).mockImplementation(() => {
289+
throw new Error('EACCES');
290+
});
291+
292+
// Should not throw
293+
const store = new ConfigStore('my-app');
294+
expect(store.didMigrate).toBe(false);
295+
});
296+
297+
it('handles legacy cleanup errors gracefully', () => {
298+
const legacyData = { userId: '500521' };
299+
300+
vi.mocked(existsSync).mockReturnValue(true);
301+
302+
vi.mocked(readFileSync).mockImplementation((path) => {
303+
if (path === legacyPath) return JSON.stringify(legacyData);
304+
throw new Error('ENOENT');
305+
});
306+
307+
vi.mocked(unlinkSync).mockImplementation(() => {
308+
throw new Error('EPERM');
309+
});
310+
311+
// Should not throw even if cleanup fails
312+
const store = new ConfigStore('my-app');
313+
expect(store.didMigrate).toBe(true);
314+
expect(store.get('userId')).toBe('500521');
120315
});
121316
});
122317
});

packages/api/src/utils/config-store.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
/**
22
* Simple configuration storage using native Node.js fs
33
* Respects XDG Base Directory specification
4+
*
5+
* On macOS, uses ~/Library/Application Support by default.
6+
* If a legacy config exists at ~/.config (from older versions),
7+
* it is automatically migrated on first access.
48
*/
59

6-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
10+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, rmdirSync } from 'node:fs';
711
import { homedir } from 'node:os';
8-
import { join } from 'node:path';
12+
import { join, dirname } from 'node:path';
913

1014
export class ConfigStore<T extends Record<string, unknown>> {
1115
private configPath: string;
1216
private cache: T | null = null;
17+
private migrated = false;
1318

1419
constructor(projectName: string) {
1520
const configDir = this.getConfigDir();
1621
this.configPath = join(configDir, projectName, 'config.json');
22+
this.migrateFromLegacyPath(projectName);
1723
}
1824

1925
private getConfigDir(): string {
@@ -32,6 +38,74 @@ export class ConfigStore<T extends Record<string, unknown>> {
3238
}
3339
}
3440

41+
/**
42+
* Migrate config from legacy ~/.config path to the macOS-native path.
43+
*
44+
* Earlier versions used ~/.config on all platforms. On macOS (without
45+
* XDG_CONFIG_HOME set), the correct path is ~/Library/Application Support.
46+
* This migrates data automatically and removes the legacy file.
47+
*/
48+
private migrateFromLegacyPath(projectName: string): void {
49+
// Only migrate on macOS when XDG_CONFIG_HOME is not set
50+
if (process.platform !== 'darwin' || process.env.XDG_CONFIG_HOME) {
51+
return;
52+
}
53+
54+
const legacyPath = join(homedir(), '.config', projectName, 'config.json');
55+
56+
// No legacy file to migrate from
57+
if (!existsSync(legacyPath)) {
58+
return;
59+
}
60+
61+
// Read legacy config
62+
let legacyData: T;
63+
try {
64+
const raw = readFileSync(legacyPath, 'utf-8');
65+
legacyData = JSON.parse(raw) as T;
66+
} catch {
67+
return;
68+
}
69+
70+
// Nothing to migrate if legacy config is empty
71+
if (Object.keys(legacyData).length === 0) {
72+
return;
73+
}
74+
75+
// Load current config (may be empty or have some values)
76+
const currentData = this.load();
77+
78+
// Merge: legacy values fill in missing keys, current values take precedence
79+
const merged = { ...legacyData, ...currentData } as T;
80+
81+
// Only write if there's actually something new to add
82+
const currentKeys = Object.keys(currentData);
83+
const legacyKeys = Object.keys(legacyData);
84+
const hasNewKeys = legacyKeys.some((key) => !currentKeys.includes(key));
85+
86+
if (hasNewKeys) {
87+
this.save(merged);
88+
this.migrated = true;
89+
}
90+
91+
// Clean up legacy file
92+
try {
93+
unlinkSync(legacyPath);
94+
// Remove directory if empty
95+
const legacyDir = dirname(legacyPath);
96+
rmdirSync(legacyDir);
97+
} catch {
98+
// Ignore errors (directory not empty, permissions, etc.)
99+
}
100+
}
101+
102+
/**
103+
* Whether a migration from legacy config path occurred.
104+
*/
105+
get didMigrate(): boolean {
106+
return this.migrated;
107+
}
108+
35109
private ensureConfigDir(): void {
36110
const dir = this.configPath.substring(0, this.configPath.lastIndexOf('/'));
37111
if (!existsSync(dir)) {

0 commit comments

Comments
 (0)