ccmv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cursor.js ADDED
@@ -0,0 +1,582 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, renameSync, statSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir, platform } from 'node:os';
4
+ import { execSync } from 'node:child_process';
5
+ import { logInfo, logOk, logError, logBackup, logFile, logReplace, logDryrun, logRollback } from './logger.js';
6
+ import { getWorkspaceHash, pathToFileUri, pathToTilde } from './utils.js';
7
+
8
+ // Cursor paths - support both macOS and Linux
9
+ const IS_MACOS = platform() === 'darwin';
10
+ const CURSOR_APP_SUPPORT = IS_MACOS
11
+ ? join(homedir(), 'Library', 'Application Support', 'Cursor')
12
+ : join(homedir(), '.config', 'Cursor');
13
+ const CURSOR_USER_DIR = join(CURSOR_APP_SUPPORT, 'User');
14
+ const CURSOR_GLOBAL_STORAGE = join(CURSOR_USER_DIR, 'globalStorage');
15
+ const CURSOR_WORKSPACE_STORAGE = join(CURSOR_USER_DIR, 'workspaceStorage');
16
+ const CURSOR_STORAGE_JSON = join(CURSOR_GLOBAL_STORAGE, 'storage.json');
17
+ const CURSOR_STATE_VSCDB = join(CURSOR_GLOBAL_STORAGE, 'state.vscdb');
18
+
19
+ let Database;
20
+
21
+ /**
22
+ * Initialize SQLite database module
23
+ */
24
+ async function initDatabase() {
25
+ if (!Database) {
26
+ try {
27
+ const betterSqlite3 = await import('better-sqlite3');
28
+ Database = betterSqlite3.default;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Detect if Cursor is installed
38
+ */
39
+ export function detectCursor(skipCursor) {
40
+ if (skipCursor) {
41
+ logInfo('Cursor update skipped (--no-cursor)');
42
+ return false;
43
+ }
44
+
45
+ if (existsSync(CURSOR_USER_DIR)) {
46
+ logOk('Cursor detected');
47
+ return true;
48
+ } else {
49
+ logInfo('Cursor not installed, skipping');
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Check if Cursor is running
56
+ */
57
+ export function checkCursorNotRunning(cursorDetected, dryRun) {
58
+ if (!cursorDetected || dryRun) {
59
+ return true;
60
+ }
61
+
62
+ try {
63
+ const result = execSync('pgrep -x "Cursor"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
64
+ if (result.trim()) {
65
+ logError('Cursor is running. Please close Cursor before migration to prevent data corruption.');
66
+ return false;
67
+ }
68
+ } catch {
69
+ // pgrep returns non-zero if no process found - that's what we want
70
+ }
71
+
72
+ logOk('Cursor is not running');
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Find workspace directories using hash calculation
78
+ */
79
+ export function findCursorWorkspaces(cursorDetected, oldPath) {
80
+ const result = {
81
+ oldWorkspaceHash: null,
82
+ workspaceDirs: [],
83
+ };
84
+
85
+ if (!cursorDetected || !existsSync(CURSOR_WORKSPACE_STORAGE)) {
86
+ return result;
87
+ }
88
+
89
+ logInfo('Calculating workspace hash...');
90
+
91
+ const hash = getWorkspaceHash(oldPath);
92
+ if (!hash) {
93
+ logInfo('Could not calculate workspace hash');
94
+ return result;
95
+ }
96
+
97
+ result.oldWorkspaceHash = hash;
98
+ logInfo(` Old path hash: ${hash}`);
99
+
100
+ const workspaceDir = join(CURSOR_WORKSPACE_STORAGE, hash);
101
+ if (existsSync(workspaceDir)) {
102
+ result.workspaceDirs.push(workspaceDir);
103
+ logOk(`Found workspace directory: ${hash}`);
104
+ } else {
105
+ logInfo('No matching Cursor workspace found for this path');
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Backup Cursor data
113
+ */
114
+ export function backupCursorData(cursorDetected, workspaceDirs, backupDir, dryRun) {
115
+ if (!cursorDetected) return;
116
+
117
+ logBackup('Backing up Cursor data...');
118
+
119
+ if (dryRun) {
120
+ logDryrun('Would backup Cursor data');
121
+ return;
122
+ }
123
+
124
+ const cursorBackupDir = join(backupDir, 'cursor', 'workspaces');
125
+ mkdirSync(cursorBackupDir, { recursive: true });
126
+
127
+ // Backup storage.json
128
+ if (existsSync(CURSOR_STORAGE_JSON)) {
129
+ cpSync(CURSOR_STORAGE_JSON, join(backupDir, 'cursor', 'storage.json'));
130
+ logBackup('storage.json');
131
+ }
132
+
133
+ // Backup global state.vscdb
134
+ if (existsSync(CURSOR_STATE_VSCDB)) {
135
+ cpSync(CURSOR_STATE_VSCDB, join(backupDir, 'cursor', 'state.vscdb'));
136
+ logBackup('state.vscdb (global)');
137
+ }
138
+
139
+ // Backup workspace directories
140
+ for (const wsDir of workspaceDirs) {
141
+ const dirname = basename(wsDir);
142
+ cpSync(wsDir, join(cursorBackupDir, dirname), { recursive: true });
143
+ logBackup(`workspace: ${dirname}`);
144
+ }
145
+
146
+ console.log('');
147
+ }
148
+
149
+ /**
150
+ * Rename Cursor workspace storage directory
151
+ */
152
+ export function renameCursorWorkspace(cursorDetected, oldWorkspaceHash, newPath, oldFileUri, newFileUri, dryRun) {
153
+ const result = {
154
+ newWorkspaceHash: null,
155
+ workspaceDirs: [],
156
+ };
157
+
158
+ if (!cursorDetected || !oldWorkspaceHash) {
159
+ return result;
160
+ }
161
+
162
+ if (dryRun) {
163
+ logDryrun('Would rename workspace directory (hash calculated after move)');
164
+ return result;
165
+ }
166
+
167
+ logInfo('Calculating new workspace hash...');
168
+
169
+ const newHash = getWorkspaceHash(newPath);
170
+ if (!newHash) {
171
+ logError('Could not calculate new workspace hash');
172
+ return result;
173
+ }
174
+
175
+ result.newWorkspaceHash = newHash;
176
+ logInfo(` New path hash: ${newHash}`);
177
+
178
+ const oldWsDir = join(CURSOR_WORKSPACE_STORAGE, oldWorkspaceHash);
179
+ const newWsDir = join(CURSOR_WORKSPACE_STORAGE, newHash);
180
+
181
+ if (oldWorkspaceHash === newHash) {
182
+ logInfo('Workspace hash unchanged (same birthtime)');
183
+ result.workspaceDirs.push(newWsDir);
184
+ return result;
185
+ }
186
+
187
+ if (existsSync(oldWsDir)) {
188
+ if (existsSync(newWsDir)) {
189
+ logInfo('Target workspace directory already exists, merging...');
190
+ // Compare state.vscdb sizes and keep the larger one
191
+ const oldDb = join(oldWsDir, 'state.vscdb');
192
+ const newDb = join(newWsDir, 'state.vscdb');
193
+
194
+ if (existsSync(oldDb) && existsSync(newDb)) {
195
+ const oldSize = statSync(oldDb).size;
196
+ const newSize = statSync(newDb).size;
197
+ if (oldSize > newSize) {
198
+ logInfo(` Using larger state.vscdb from old workspace (${oldSize} > ${newSize} bytes)`);
199
+ cpSync(oldDb, newDb);
200
+ }
201
+ } else if (existsSync(oldDb)) {
202
+ cpSync(oldDb, newDb);
203
+ }
204
+
205
+ // Copy other files from old (don't overwrite existing)
206
+ const entries = readdirSync(oldWsDir, { withFileTypes: true });
207
+ for (const entry of entries) {
208
+ const src = join(oldWsDir, entry.name);
209
+ const dest = join(newWsDir, entry.name);
210
+ if (!existsSync(dest)) {
211
+ if (entry.isDirectory()) {
212
+ cpSync(src, dest, { recursive: true });
213
+ } else {
214
+ cpSync(src, dest);
215
+ }
216
+ }
217
+ }
218
+ rmSync(oldWsDir, { recursive: true });
219
+ } else {
220
+ renameSync(oldWsDir, newWsDir);
221
+ }
222
+ result.workspaceDirs.push(newWsDir);
223
+ logOk('Renamed workspace directory');
224
+ }
225
+
226
+ // Merge duplicate workspaces
227
+ mergeDuplicateWorkspaces(newHash, newFileUri);
228
+
229
+ return result;
230
+ }
231
+
232
+ /**
233
+ * Merge duplicate workspaces pointing to the same path
234
+ */
235
+ function mergeDuplicateWorkspaces(newWorkspaceHash, newFileUri) {
236
+ const targetDir = join(CURSOR_WORKSPACE_STORAGE, newWorkspaceHash);
237
+ if (!existsSync(targetDir)) return;
238
+
239
+ logInfo('Checking for duplicate workspaces...');
240
+
241
+ let foundDuplicates = false;
242
+ const entries = readdirSync(CURSOR_WORKSPACE_STORAGE, { withFileTypes: true });
243
+
244
+ for (const entry of entries) {
245
+ if (!entry.isDirectory() || entry.name === newWorkspaceHash) continue;
246
+
247
+ const workspaceJson = join(CURSOR_WORKSPACE_STORAGE, entry.name, 'workspace.json');
248
+ if (!existsSync(workspaceJson)) continue;
249
+
250
+ try {
251
+ const content = readFileSync(workspaceJson, 'utf-8');
252
+ if (content.includes(newFileUri)) {
253
+ foundDuplicates = true;
254
+ logInfo(` Found duplicate workspace: ${entry.name}`);
255
+
256
+ // Compare state.vscdb and merge if duplicate has more data
257
+ const dupDb = join(CURSOR_WORKSPACE_STORAGE, entry.name, 'state.vscdb');
258
+ const targetDb = join(targetDir, 'state.vscdb');
259
+
260
+ if (existsSync(dupDb)) {
261
+ const dupSize = statSync(dupDb).size;
262
+ const targetSize = existsSync(targetDb) ? statSync(targetDb).size : 0;
263
+
264
+ if (dupSize > targetSize) {
265
+ logInfo(` Merging larger state.vscdb (${dupSize} > ${targetSize} bytes)`);
266
+ cpSync(dupDb, targetDb);
267
+ }
268
+ }
269
+
270
+ // Remove the duplicate workspace
271
+ rmSync(join(CURSOR_WORKSPACE_STORAGE, entry.name), { recursive: true });
272
+ logOk(' Removed duplicate workspace');
273
+ }
274
+ } catch {
275
+ // Skip on error
276
+ }
277
+ }
278
+
279
+ if (!foundDuplicates) {
280
+ logInfo(' No duplicates found');
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Update Cursor storage.json
286
+ */
287
+ export function updateCursorStorageJson(cursorDetected, oldPath, newPath, oldFileUri, newFileUri, dryRun) {
288
+ if (!cursorDetected || !existsSync(CURSOR_STORAGE_JSON)) return;
289
+
290
+ logInfo('Updating Cursor storage.json...');
291
+
292
+ const home = homedir();
293
+ const oldTildePath = pathToTilde(oldPath);
294
+ const newTildePath = pathToTilde(newPath);
295
+
296
+ const content = readFileSync(CURSOR_STORAGE_JSON, 'utf-8');
297
+
298
+ // Count occurrences
299
+ const regex = new RegExp(escapeRegex(oldPath) + '|' + escapeRegex(oldFileUri), 'g');
300
+ const matches = content.match(regex) || [];
301
+ const count = matches.length;
302
+
303
+ if (count > 0) {
304
+ if (dryRun) {
305
+ logReplace(`${count} occurrences would be updated`);
306
+ } else {
307
+ let newContent = content;
308
+ // Replace with delimiters to avoid partial matches
309
+ newContent = replaceWithDelimiters(newContent, oldFileUri, newFileUri);
310
+ newContent = replaceWithDelimiters(newContent, oldPath, newPath);
311
+ newContent = replaceWithDelimiters(newContent, oldTildePath, newTildePath);
312
+ writeFileSync(CURSOR_STORAGE_JSON, newContent);
313
+ logReplace(`${count} occurrences updated`);
314
+ }
315
+ } else {
316
+ logInfo('No matching entries in storage.json');
317
+ }
318
+
319
+ console.log('');
320
+ }
321
+
322
+ /**
323
+ * Update Cursor global state.vscdb (SQLite)
324
+ */
325
+ export async function updateCursorStateVscdb(cursorDetected, oldPath, newPath, oldFileUri, newFileUri, dryRun) {
326
+ if (!cursorDetected || !existsSync(CURSOR_STATE_VSCDB)) return;
327
+
328
+ logInfo('Updating Cursor global state.vscdb...');
329
+
330
+ if (!(await initDatabase())) {
331
+ logError('Could not load SQLite module');
332
+ return;
333
+ }
334
+
335
+ const home = homedir();
336
+ const oldTildePath = pathToTilde(oldPath);
337
+ const newTildePath = pathToTilde(newPath);
338
+
339
+ try {
340
+ const db = new Database(CURSOR_STATE_VSCDB);
341
+
342
+ // Count matching rows
343
+ const countStmt = db.prepare("SELECT COUNT(*) as count FROM ItemTable WHERE value LIKE ?");
344
+ const result = countStmt.get(`%${oldPath}%`);
345
+ const count = result?.count || 0;
346
+
347
+ if (dryRun) {
348
+ logReplace(`${count} rows would be updated`);
349
+ } else if (count > 0) {
350
+ // Replace with delimiters to avoid partial matches
351
+ db.exec('BEGIN TRANSACTION');
352
+
353
+ const updatePatterns = [
354
+ [oldFileUri + '"', newFileUri + '"'],
355
+ [oldFileUri + '/', newFileUri + '/'],
356
+ [oldFileUri + ',', newFileUri + ','],
357
+ [oldPath + '"', newPath + '"'],
358
+ [oldPath + '/', newPath + '/'],
359
+ [oldPath + ',', newPath + ','],
360
+ [oldTildePath + '"', newTildePath + '"'],
361
+ [oldTildePath + '/', newTildePath + '/'],
362
+ [oldTildePath + ',', newTildePath + ','],
363
+ ];
364
+
365
+ for (const [oldPattern, newPattern] of updatePatterns) {
366
+ db.prepare(
367
+ "UPDATE ItemTable SET value = REPLACE(value, ?, ?) WHERE value LIKE ?"
368
+ ).run(oldPattern, newPattern, `%${oldPattern}%`);
369
+ }
370
+
371
+ db.exec('COMMIT');
372
+ logReplace(`${count} rows updated`);
373
+ } else {
374
+ logInfo('No matching entries in global state.vscdb');
375
+ }
376
+
377
+ db.close();
378
+ } catch (err) {
379
+ logError(`Failed to update state.vscdb: ${err.message}`);
380
+ }
381
+
382
+ console.log('');
383
+ }
384
+
385
+ /**
386
+ * Update Cursor workspace storage
387
+ */
388
+ export async function updateCursorWorkspaceStorage(cursorDetected, workspaceDirs, oldPath, newPath, oldFileUri, newFileUri, dryRun) {
389
+ if (!cursorDetected || workspaceDirs.length === 0) return;
390
+
391
+ logInfo('Updating Cursor workspace storage...');
392
+
393
+ if (!(await initDatabase())) {
394
+ logError('Could not load SQLite module');
395
+ return;
396
+ }
397
+
398
+ const home = homedir();
399
+ const oldTildePath = pathToTilde(oldPath);
400
+ const newTildePath = pathToTilde(newPath);
401
+
402
+ for (const wsDir of workspaceDirs) {
403
+ const dirname = basename(wsDir);
404
+ logFile(`Workspace: ${dirname}`);
405
+
406
+ // Update workspace.json
407
+ const workspaceJson = join(wsDir, 'workspace.json');
408
+ if (existsSync(workspaceJson)) {
409
+ const content = readFileSync(workspaceJson, 'utf-8');
410
+ const regex = new RegExp(escapeRegex(oldPath) + '|' + escapeRegex(oldFileUri), 'g');
411
+ const matches = content.match(regex) || [];
412
+ const count = matches.length;
413
+
414
+ if (count > 0) {
415
+ if (dryRun) {
416
+ logReplace(`workspace.json: ${count} occurrences would be updated`);
417
+ } else {
418
+ let newContent = content;
419
+ newContent = replaceWithDelimiters(newContent, oldFileUri, newFileUri);
420
+ newContent = replaceWithDelimiters(newContent, oldPath, newPath);
421
+ newContent = replaceWithDelimiters(newContent, oldTildePath, newTildePath);
422
+ writeFileSync(workspaceJson, newContent);
423
+ logReplace(`workspace.json: ${count} occurrences updated`);
424
+ }
425
+ }
426
+ }
427
+
428
+ // Update state.vscdb
429
+ const stateVscdb = join(wsDir, 'state.vscdb');
430
+ if (existsSync(stateVscdb)) {
431
+ try {
432
+ const db = new Database(stateVscdb);
433
+
434
+ const countStmt = db.prepare("SELECT COUNT(*) as count FROM ItemTable WHERE value LIKE ?");
435
+ const result = countStmt.get(`%${oldPath}%`);
436
+ const dbCount = result?.count || 0;
437
+
438
+ if (dbCount > 0) {
439
+ if (dryRun) {
440
+ logReplace(`state.vscdb: ${dbCount} rows would be updated`);
441
+ } else {
442
+ db.exec('BEGIN TRANSACTION');
443
+
444
+ const updatePatterns = [
445
+ [oldFileUri + '"', newFileUri + '"'],
446
+ [oldFileUri + '/', newFileUri + '/'],
447
+ [oldPath + '"', newPath + '"'],
448
+ [oldPath + '/', newPath + '/'],
449
+ [oldTildePath + '"', newTildePath + '"'],
450
+ [oldTildePath + '/', newTildePath + '/'],
451
+ ];
452
+
453
+ for (const [oldPattern, newPattern] of updatePatterns) {
454
+ db.prepare(
455
+ "UPDATE ItemTable SET value = REPLACE(value, ?, ?) WHERE value LIKE ?"
456
+ ).run(oldPattern, newPattern, `%${oldPattern}%`);
457
+ }
458
+
459
+ db.exec('COMMIT');
460
+ logReplace(`state.vscdb: ${dbCount} rows updated`);
461
+ }
462
+ }
463
+
464
+ db.close();
465
+ } catch (err) {
466
+ logError(`Failed to update workspace state.vscdb: ${err.message}`);
467
+ }
468
+ }
469
+ }
470
+
471
+ console.log('');
472
+ }
473
+
474
+ /**
475
+ * Verify Cursor migration
476
+ */
477
+ export function verifyCursorMigration(cursorDetected, workspaceDirs, oldPath, newPath, dryRun) {
478
+ if (!cursorDetected || dryRun) return true;
479
+
480
+ logInfo('Verifying Cursor migration...');
481
+
482
+ let hasError = false;
483
+
484
+ // Check storage.json
485
+ if (existsSync(CURSOR_STORAGE_JSON)) {
486
+ const content = readFileSync(CURSOR_STORAGE_JSON, 'utf-8');
487
+ // Find OLD_PATH but exclude lines with NEW_PATH (to handle SPNFY vs SPNFY-test case)
488
+ const lines = content.split('\n');
489
+ let count = 0;
490
+ for (const line of lines) {
491
+ if (line.includes(oldPath) && !line.includes(newPath)) {
492
+ count++;
493
+ }
494
+ }
495
+ if (count > 0) {
496
+ logError(`Found ${count} stale references in storage.json`);
497
+ hasError = true;
498
+ }
499
+ }
500
+
501
+ // Check workspaces
502
+ for (const wsDir of workspaceDirs) {
503
+ const workspaceJson = join(wsDir, 'workspace.json');
504
+ if (existsSync(workspaceJson)) {
505
+ const content = readFileSync(workspaceJson, 'utf-8');
506
+ const lines = content.split('\n');
507
+ let count = 0;
508
+ for (const line of lines) {
509
+ if (line.includes(oldPath) && !line.includes(newPath)) {
510
+ count++;
511
+ }
512
+ }
513
+ if (count > 0) {
514
+ logError(`Found ${count} stale references in ${basename(wsDir)}/workspace.json`);
515
+ hasError = true;
516
+ }
517
+ }
518
+ }
519
+
520
+ if (hasError) {
521
+ return false;
522
+ }
523
+
524
+ logOk('Cursor migration verified');
525
+ console.log('');
526
+ return true;
527
+ }
528
+
529
+ /**
530
+ * Rollback Cursor data from backup
531
+ */
532
+ export function rollbackCursor(backupDir) {
533
+ const cursorBackupDir = join(backupDir, 'cursor');
534
+
535
+ // Restore storage.json
536
+ const backupStorageJson = join(cursorBackupDir, 'storage.json');
537
+ if (existsSync(backupStorageJson)) {
538
+ cpSync(backupStorageJson, CURSOR_STORAGE_JSON);
539
+ logRollback('Restored Cursor storage.json');
540
+ }
541
+
542
+ // Restore global state.vscdb
543
+ const backupStateVscdb = join(cursorBackupDir, 'state.vscdb');
544
+ if (existsSync(backupStateVscdb)) {
545
+ cpSync(backupStateVscdb, CURSOR_STATE_VSCDB);
546
+ logRollback('Restored Cursor global state.vscdb');
547
+ }
548
+
549
+ // Restore workspace directories
550
+ const workspacesBackupDir = join(cursorBackupDir, 'workspaces');
551
+ if (existsSync(workspacesBackupDir)) {
552
+ const entries = readdirSync(workspacesBackupDir, { withFileTypes: true });
553
+ for (const entry of entries) {
554
+ if (entry.isDirectory()) {
555
+ const targetDir = join(CURSOR_WORKSPACE_STORAGE, entry.name);
556
+ if (existsSync(targetDir)) {
557
+ rmSync(targetDir, { recursive: true });
558
+ }
559
+ cpSync(join(workspacesBackupDir, entry.name), targetDir, { recursive: true });
560
+ logRollback(`Restored Cursor workspace: ${entry.name}`);
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ // Helper functions
567
+
568
+ function escapeRegex(string) {
569
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
570
+ }
571
+
572
+ function replaceWithDelimiters(content, oldStr, newStr) {
573
+ // Replace with delimiters to avoid partial matches
574
+ const delimiters = ['"', '/', ','];
575
+ let result = content;
576
+ for (const delim of delimiters) {
577
+ result = result.split(oldStr + delim).join(newStr + delim);
578
+ }
579
+ return result;
580
+ }
581
+
582
+ export { CURSOR_APP_SUPPORT, CURSOR_USER_DIR, CURSOR_WORKSPACE_STORAGE };