claude-switch-profile 1.0.2 → 1.4.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.
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, cpSync, rmSync, lstatSync, statSync, copyFileSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { MANAGED_ITEMS, COPY_ITEMS, COPY_DIRS, CLAUDE_DIR, DEFAULT_PROFILE } from './constants.js';
4
+ import {
5
+ getActive,
6
+ getProfileDir,
7
+ getProfileMeta,
8
+ getRuntimeDir,
9
+ markRuntimeInitialized,
10
+ markProfileLaunched,
11
+ } from './profile-store.js';
12
+
13
+ const removePath = (targetPath) => {
14
+ if (!existsSync(targetPath)) return;
15
+ rmSync(targetPath, { recursive: true, force: true });
16
+ };
17
+
18
+ const shouldSyncDir = (src, dest) => {
19
+ if (!existsSync(dest)) return true;
20
+
21
+ try {
22
+ const pending = [[src, dest]];
23
+
24
+ while (pending.length > 0) {
25
+ const [srcDir, destDir] = pending.pop();
26
+ const srcEntries = readdirSync(srcDir, { withFileTypes: true });
27
+ const destEntries = readdirSync(destDir, { withFileTypes: true });
28
+
29
+ if (srcEntries.length !== destEntries.length) return true;
30
+
31
+ const destMap = new Map(destEntries.map((entry) => [entry.name, entry]));
32
+
33
+ for (const srcEntry of srcEntries) {
34
+ const destEntry = destMap.get(srcEntry.name);
35
+ if (!destEntry) return true;
36
+
37
+ const srcPath = join(srcDir, srcEntry.name);
38
+ const destPath = join(destDir, srcEntry.name);
39
+
40
+ if (srcEntry.isDirectory()) {
41
+ if (!destEntry.isDirectory()) return true;
42
+ pending.push([srcPath, destPath]);
43
+ continue;
44
+ }
45
+
46
+ if (srcEntry.isFile()) {
47
+ if (!destEntry.isFile()) return true;
48
+ const srcStat = statSync(srcPath);
49
+ const destStat = statSync(destPath);
50
+ if (srcStat.size !== destStat.size) return true;
51
+ if (srcStat.mtimeMs !== destStat.mtimeMs) return true;
52
+ }
53
+ }
54
+ }
55
+
56
+ return false;
57
+ } catch {
58
+ return true;
59
+ }
60
+ };
61
+
62
+ const replacePathVariants = (content, fromPath, toPath) => {
63
+ const fromEscaped = fromPath.replaceAll('\\', '\\\\');
64
+ const toEscaped = toPath.replaceAll('\\', '\\\\');
65
+ const fromFwd = fromPath.replaceAll('\\', '/');
66
+ const toFwd = toPath.replaceAll('\\', '/');
67
+
68
+ let updated = content;
69
+ updated = updated.replaceAll(fromEscaped, toEscaped);
70
+ updated = updated.replaceAll(fromFwd, toFwd);
71
+ if (fromPath !== fromEscaped) {
72
+ updated = updated.replaceAll(fromPath, toPath);
73
+ }
74
+
75
+ return updated;
76
+ };
77
+
78
+ const rewriteSettingsForRuntime = (runtimeDir, sourceDir) => {
79
+ const settingsPath = join(runtimeDir, 'settings.json');
80
+ if (!existsSync(settingsPath)) return;
81
+
82
+ try {
83
+ const raw = readFileSync(settingsPath, 'utf-8');
84
+ let updated = raw;
85
+
86
+ updated = replacePathVariants(updated, sourceDir, runtimeDir);
87
+ updated = replacePathVariants(updated, CLAUDE_DIR, runtimeDir);
88
+
89
+ if (updated !== raw) {
90
+ writeFileSync(settingsPath, updated);
91
+ }
92
+ } catch {
93
+ // Best effort
94
+ }
95
+ };
96
+
97
+ const getStaticItems = () => {
98
+ return [...new Set([...MANAGED_ITEMS, ...COPY_ITEMS, ...COPY_DIRS])];
99
+ };
100
+
101
+ const resolveSourceDir = (profileName) => {
102
+ if (profileName === DEFAULT_PROFILE) {
103
+ return CLAUDE_DIR;
104
+ }
105
+
106
+ const active = getActive();
107
+ if (active === profileName) {
108
+ return CLAUDE_DIR;
109
+ }
110
+
111
+ return getProfileDir(profileName);
112
+ };
113
+
114
+ const resolveItemSource = (profileName, item) => {
115
+ const profileDir = profileName === DEFAULT_PROFILE ? null : getProfileDir(profileName);
116
+ const active = getActive();
117
+ const profileIsActive = active === profileName;
118
+
119
+ // When profile is active or default, items live in CLAUDE_DIR
120
+ if (profileIsActive || profileName === DEFAULT_PROFILE) {
121
+ const claudePath = join(CLAUDE_DIR, item);
122
+ if (existsSync(claudePath)) return claudePath;
123
+ // Fallback: items may still be in profileDir (not yet moved by `use`)
124
+ if (profileDir) {
125
+ const profilePath = join(profileDir, item);
126
+ if (existsSync(profilePath)) return profilePath;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // Profile is NOT active — items should be in profileDir
132
+ const profilePath = join(profileDir, item);
133
+ if (existsSync(profilePath)) return profilePath;
134
+
135
+ return null;
136
+ };
137
+
138
+ export const syncStaticConfig = (profileName, runtimeDir) => {
139
+ const sourceDir = resolveSourceDir(profileName);
140
+ const staticItems = getStaticItems();
141
+
142
+ mkdirSync(runtimeDir, { recursive: true });
143
+
144
+ for (const item of staticItems) {
145
+ const src = resolveItemSource(profileName, item);
146
+ const dest = join(runtimeDir, item);
147
+
148
+ if (!src) {
149
+ // Source doesn't exist anywhere — skip (don't delete existing runtime items)
150
+ continue;
151
+ }
152
+
153
+ const stat = lstatSync(src);
154
+
155
+ if (stat.isDirectory()) {
156
+ if (!shouldSyncDir(src, dest)) continue;
157
+ removePath(dest);
158
+ cpSync(src, dest, { recursive: true });
159
+ continue;
160
+ }
161
+
162
+ if (stat.isFile()) {
163
+ mkdirSync(dirname(dest), { recursive: true });
164
+ copyFileSync(src, dest);
165
+ }
166
+ }
167
+
168
+ rewriteSettingsForRuntime(runtimeDir, sourceDir);
169
+
170
+ return { runtimeDir, sourceDir };
171
+ };
172
+
173
+ export const seedRuntimeIfNeeded = (profileName) => {
174
+ const meta = getProfileMeta(profileName);
175
+ const runtimeDir = meta?.runtimeDir || getRuntimeDir(profileName);
176
+
177
+ syncStaticConfig(profileName, runtimeDir);
178
+
179
+ if (!meta || !meta.runtimeInitializedAt || meta.runtimeDir !== runtimeDir) {
180
+ markRuntimeInitialized(profileName, runtimeDir);
181
+ } else {
182
+ markProfileLaunched(profileName);
183
+ }
184
+
185
+ return runtimeDir;
186
+ };
187
+
188
+ export const ensureRuntimeInstance = (profileName) => {
189
+ return seedRuntimeIfNeeded(profileName);
190
+ };
package/src/safety.js CHANGED
@@ -1,39 +1,45 @@
1
1
  import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, cpSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { execFileSync } from 'node:child_process';
4
3
  import { PROFILES_DIR, LOCK_FILE, BACKUP_DIR, CLAUDE_DIR, COPY_ITEMS, COPY_DIRS } from './constants.js';
5
- import { readCurrentSymlinks } from './symlink-manager.js';
4
+ import { readCurrentItems } from './item-manager.js';
5
+ import { findProcess } from './platform.js';
6
6
  import { warn } from './output-helpers.js';
7
7
 
8
+ const CLAUDE_RUNNING_ERROR = 'Claude Code appears to be running. Close all Claude sessions before switching profiles.';
9
+
8
10
  const MAX_BACKUPS = 2;
9
11
 
10
- // Acquire a lock file — atomic O_EXCL prevents TOCTOU race
11
- export const acquireLock = () => {
12
- const lockPath = join(PROFILES_DIR, LOCK_FILE);
12
+ const isProcessRunning = (pid) => {
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ };
20
+
21
+ const acquireLockFile = (lockPath) => {
22
+ mkdirSync(PROFILES_DIR, { recursive: true });
13
23
 
14
24
  try {
15
25
  writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
16
26
  } catch (err) {
17
- if (err.code === 'EEXIST') {
18
- // Lock exists — check if stale
19
- const content = readFileSync(lockPath, 'utf-8').trim();
20
- const pid = parseInt(content, 10);
21
-
22
- if (pid && !isProcessRunning(pid)) {
23
- unlinkSync(lockPath);
24
- writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
25
- } else {
26
- throw new Error(`Another csp operation is running (PID: ${content}). Remove ${lockPath} if stale.`);
27
- }
28
- } else {
29
- throw err;
27
+ if (err.code !== 'EEXIST') throw err;
28
+
29
+ const content = readFileSync(lockPath, 'utf-8').trim();
30
+ const pid = parseInt(content, 10);
31
+
32
+ if (pid && !isProcessRunning(pid)) {
33
+ unlinkSync(lockPath);
34
+ writeFileSync(lockPath, String(process.pid) + '\n', { flag: 'wx' });
35
+ return;
30
36
  }
37
+
38
+ throw new Error(`Another csp operation is running (PID: ${content}). Remove ${lockPath} if stale.`);
31
39
  }
32
40
  };
33
41
 
34
- // Release the lock file
35
- export const releaseLock = () => {
36
- const lockPath = join(PROFILES_DIR, LOCK_FILE);
42
+ const releaseLockFile = (lockPath) => {
37
43
  try {
38
44
  if (existsSync(lockPath)) unlinkSync(lockPath);
39
45
  } catch {
@@ -41,6 +47,18 @@ export const releaseLock = () => {
41
47
  }
42
48
  };
43
49
 
50
+ // Acquire a lock file — atomic O_EXCL prevents TOCTOU race
51
+ export const acquireLock = () => {
52
+ const lockPath = join(PROFILES_DIR, LOCK_FILE);
53
+ acquireLockFile(lockPath);
54
+ };
55
+
56
+ // Release the lock file
57
+ export const releaseLock = () => {
58
+ const lockPath = join(PROFILES_DIR, LOCK_FILE);
59
+ releaseLockFile(lockPath);
60
+ };
61
+
44
62
  // Wrapper that acquires/releases lock around async function
45
63
  export const withLock = async (fn) => {
46
64
  acquireLock();
@@ -51,24 +69,37 @@ export const withLock = async (fn) => {
51
69
  }
52
70
  };
53
71
 
54
- // Check if a PID is still running
55
- const isProcessRunning = (pid) => {
72
+ const runtimeLockName = (profileName) => {
73
+ return `.runtime.${profileName}.lock`;
74
+ };
75
+
76
+ export const acquireRuntimeLock = (profileName) => {
77
+ const lockPath = join(PROFILES_DIR, runtimeLockName(profileName));
78
+ acquireLockFile(lockPath);
79
+ };
80
+
81
+ export const releaseRuntimeLock = (profileName) => {
82
+ const lockPath = join(PROFILES_DIR, runtimeLockName(profileName));
83
+ releaseLockFile(lockPath);
84
+ };
85
+
86
+ export const withRuntimeLock = async (profileName, fn) => {
87
+ acquireRuntimeLock(profileName);
56
88
  try {
57
- process.kill(pid, 0);
58
- return true;
59
- } catch {
60
- return false;
89
+ return await fn();
90
+ } finally {
91
+ releaseRuntimeLock(profileName);
61
92
  }
62
93
  };
63
94
 
64
- // Check if claude process is running
95
+ // Check if claude process is running (cross-platform)
65
96
  export const isClaudeRunning = () => {
66
- try {
67
- const result = execFileSync('pgrep', ['-x', 'claude'], { encoding: 'utf-8' });
68
- return result.trim().length > 0;
69
- } catch {
70
- return false;
97
+ if (process.env.NODE_ENV === 'test') {
98
+ if (process.env.CSP_TEST_CLAUDE_RUNNING === '1') return true;
99
+ if (process.env.CSP_TEST_CLAUDE_RUNNING === '0') return false;
71
100
  }
101
+
102
+ return findProcess('claude');
72
103
  };
73
104
 
74
105
  // Print warning if Claude is detected running
@@ -78,6 +109,12 @@ export const warnIfClaudeRunning = () => {
78
109
  }
79
110
  };
80
111
 
112
+ export const assertClaudeNotRunning = (processChecker = isClaudeRunning) => {
113
+ if (processChecker()) {
114
+ throw new Error(CLAUDE_RUNNING_ERROR);
115
+ }
116
+ };
117
+
81
118
  // Create backup of current managed items, prune old backups
82
119
  export const createBackup = () => {
83
120
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -85,8 +122,8 @@ export const createBackup = () => {
85
122
  const backupPath = join(backupBase, timestamp);
86
123
  mkdirSync(backupPath, { recursive: true });
87
124
 
88
- // Save symlink targets
89
- const sourceMap = readCurrentSymlinks();
125
+ // Save managed item map
126
+ const sourceMap = readCurrentItems();
90
127
  writeFileSync(join(backupPath, 'source.json'), JSON.stringify(sourceMap, null, 2) + '\n');
91
128
 
92
129
  // Copy mutable files
@@ -1,69 +0,0 @@
1
- import { existsSync, readlinkSync, symlinkSync, unlinkSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
- import { CLAUDE_DIR, SYMLINK_ITEMS, SOURCE_FILE } from './constants.js';
4
-
5
- // Read current symlink targets from ~/.claude for all SYMLINK_ITEMS
6
- export const readCurrentSymlinks = () => {
7
- const sourceMap = {};
8
- for (const item of SYMLINK_ITEMS) {
9
- const itemPath = join(CLAUDE_DIR, item);
10
- try {
11
- if (existsSync(itemPath) && lstatSync(itemPath).isSymbolicLink()) {
12
- sourceMap[item] = resolve(CLAUDE_DIR, readlinkSync(itemPath));
13
- }
14
- } catch {
15
- // Item doesn't exist or isn't readable — skip
16
- }
17
- }
18
- return sourceMap;
19
- };
20
-
21
- // Remove all managed symlinks from ~/.claude
22
- export const removeSymlinks = () => {
23
- for (const item of SYMLINK_ITEMS) {
24
- const itemPath = join(CLAUDE_DIR, item);
25
- try {
26
- if (existsSync(itemPath) && lstatSync(itemPath).isSymbolicLink()) {
27
- unlinkSync(itemPath);
28
- }
29
- } catch {
30
- // Already gone or not a symlink — skip
31
- }
32
- }
33
- };
34
-
35
- // Create symlinks in ~/.claude from a sourceMap object
36
- export const createSymlinks = (sourceMap) => {
37
- for (const [item, target] of Object.entries(sourceMap)) {
38
- if (!SYMLINK_ITEMS.includes(item)) continue;
39
- const itemPath = join(CLAUDE_DIR, item);
40
-
41
- // Remove existing if present
42
- try {
43
- if (lstatSync(itemPath)) unlinkSync(itemPath);
44
- } catch {
45
- // Doesn't exist — fine
46
- }
47
-
48
- // Only create if target exists
49
- if (existsSync(target)) {
50
- symlinkSync(target, itemPath);
51
- }
52
- }
53
- };
54
-
55
- // Save current symlink targets to profileDir/source.json
56
- export const saveSymlinks = (profileDir) => {
57
- const sourceMap = readCurrentSymlinks();
58
- writeFileSync(join(profileDir, SOURCE_FILE), JSON.stringify(sourceMap, null, 2) + '\n');
59
- return sourceMap;
60
- };
61
-
62
- // Read source.json from profileDir and create symlinks
63
- export const restoreSymlinks = (profileDir) => {
64
- const sourcePath = join(profileDir, SOURCE_FILE);
65
- if (!existsSync(sourcePath)) return {};
66
- const sourceMap = JSON.parse(readFileSync(sourcePath, 'utf-8'));
67
- createSymlinks(sourceMap);
68
- return sourceMap;
69
- };