claude-dev-env 8.88.1 → 8.88.3

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.
@@ -1,5 +1,8 @@
1
- import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { lstatSync, mkdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import { DEFAULT_CODEX_DIRECTORY_NAME } from './install-constants.mjs';
3
6
 
4
7
  export const SOL_PROMPT_DIRECTORY_NAME = 'system-prompts';
5
8
  export const SOL_PROMPT_FILE_NAME = 'codex-sol.md';
@@ -53,6 +56,91 @@ function readCodexConfig(configPath) {
53
56
  return { isReadable: true, currentText: readFileSync(configPath, 'utf8') };
54
57
  }
55
58
 
59
+ /**
60
+ * Return the Codex home whose folder holds the prompt the config.toml setting names.
61
+ *
62
+ * Account sync links each account home's config.toml to the main Codex
63
+ * home's file: a symbolic link, or a hard link when the account cannot
64
+ * create one. Every home that shares that file shares one setting, so the
65
+ * prompt it names belongs in the main home. Any other home uses its own
66
+ * folder, including a home whose config.toml links into a dotfiles folder.
67
+ *
68
+ * @param {string} codexHome
69
+ * @returns {string}
70
+ */
71
+ export function codexConfigDirectory(codexHome) {
72
+ const mainCodexHome = join(homedir(), DEFAULT_CODEX_DIRECTORY_NAME);
73
+ return sharesMainConfig(codexHome, mainCodexHome) ? mainCodexHome : codexHome;
74
+ }
75
+
76
+ function sharesMainConfig(codexHome, mainCodexHome) {
77
+ return isSameFile(join(codexHome, CODEX_CONFIG_FILE_NAME), join(mainCodexHome, CODEX_CONFIG_FILE_NAME));
78
+ }
79
+
80
+ function isSameFile(firstPath, secondPath) {
81
+ try {
82
+ const firstEntry = statSync(firstPath, { bigint: true, throwIfNoEntry: false });
83
+ const secondEntry = statSync(secondPath, { bigint: true, throwIfNoEntry: false });
84
+ return Boolean(firstEntry && secondEntry && firstEntry.dev === secondEntry.dev && firstEntry.ino === secondEntry.ino);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * True when CODEX_HOME is an account home whose config.toml is the main Codex home's file.
92
+ *
93
+ * Such a home borrows the setting and the prompt from the main home, so
94
+ * uninstalling from it leaves both alone. The homes are compared as folders
95
+ * on disk, so a CODEX_HOME spelled with other letter case or through a link
96
+ * still counts as the main home.
97
+ *
98
+ * @param {string} codexHome
99
+ * @returns {boolean}
100
+ */
101
+ export function codexConfigIsBorrowed(codexHome) {
102
+ const mainCodexHome = join(homedir(), DEFAULT_CODEX_DIRECTORY_NAME);
103
+ return sharesMainConfig(codexHome, mainCodexHome) && !isSameFile(codexHome, mainCodexHome);
104
+ }
105
+
106
+ /**
107
+ * Return where the installer keeps the Codex copy of the trimmed Sol prompt.
108
+ *
109
+ * The copy sits in CODEX_HOME, or in the main Codex home when CODEX_HOME is
110
+ * an account home that shares its config.toml, so the file the setting names
111
+ * stays put when any claude-dev-env version, older ones included, prunes or
112
+ * rewrites the managed files under ~/.claude, and every home that shares that
113
+ * config.toml names the same copy. A missing file makes every Codex command
114
+ * fail before it starts.
115
+ *
116
+ * @param {string} codexHome
117
+ * @returns {string}
118
+ */
119
+ export function codexSolPromptPath(codexHome) {
120
+ return join(codexConfigDirectory(codexHome), SOL_PROMPT_FILE_NAME);
121
+ }
122
+
123
+ /**
124
+ * Copy the trimmed Sol prompt to the folder `codexSolPromptPath` names.
125
+ *
126
+ * An account home that shares the main config.toml also loses the copy an earlier
127
+ * install left in its own folder, since no setting names that copy any more.
128
+ *
129
+ * @param {string} codexHome
130
+ * @param {string} sourcePromptPath The prompt file the package ships.
131
+ * @returns {string|null} The prompt copy's path when this call wrote it, and null when it was already current.
132
+ */
133
+ export function installCodexSolPrompt(codexHome, sourcePromptPath) {
134
+ if (codexConfigIsBorrowed(codexHome)) rmSync(join(codexHome, SOL_PROMPT_FILE_NAME), { force: true });
135
+ const promptPath = codexSolPromptPath(codexHome);
136
+ const promptText = readFileSync(sourcePromptPath, 'utf8');
137
+ const promptEntry = lstatSync(promptPath, { throwIfNoEntry: false });
138
+ if (promptEntry?.isFile() && readFileSync(promptPath, 'utf8') === promptText) return null;
139
+ mkdirSync(dirname(promptPath), { recursive: true });
140
+ writeFileSync(promptPath, promptText, 'utf8');
141
+ return promptPath;
142
+ }
143
+
56
144
  export function codexSolSettingSnapshotPaths(codexHome) {
57
145
  const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
58
146
  const { isReadable, currentText } = readCodexConfig(configPath);
@@ -88,12 +176,21 @@ export function writeCodexSolSetting(codexHome, instructionsPath) {
88
176
  * Remove the package's `model_instructions_file` line from CODEX_HOME/config.toml.
89
177
  *
90
178
  * The rest of the file stays as the user wrote it. A file that held only the
91
- * package line is removed.
179
+ * package line is removed. The prompt copy is removed in every case, because
180
+ * install writes it even when the user's own setting kept the package line
181
+ * out. An account home that shares the main config.toml only drops its own
182
+ * leftover copy: the shared setting and prompt belong to the main home, and
183
+ * other homes still read them.
92
184
  *
93
185
  * @param {string} codexHome
94
186
  * @returns {string|null} The config path when it changed, or null when nothing was removed.
95
187
  */
96
188
  export function removeCodexSolSetting(codexHome) {
189
+ if (codexConfigIsBorrowed(codexHome)) {
190
+ rmSync(join(codexHome, SOL_PROMPT_FILE_NAME), { force: true });
191
+ return null;
192
+ }
193
+ rmSync(codexSolPromptPath(codexHome), { force: true });
97
194
  const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
98
195
  const { isReadable, currentText } = readCodexConfig(configPath);
99
196
  if (!isReadable || currentText === null) return null;
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
@@ -129,7 +129,7 @@ test('a missing config needs no prior snapshot', (context) => {
129
129
  test('full install points config.toml model_instructions_file at the installed trimmed prompt', (context) => {
130
130
  const homeDirectory = makeScratchHome(context);
131
131
  const configPath = join(homeDirectory, '.codex', CODEX_CONFIG_FILE_NAME);
132
- const installedPromptPath = join(homeDirectory, '.claude', 'system-prompts', 'codex-sol.md');
132
+ const installedPromptPath = join(homeDirectory, '.codex', 'codex-sol.md');
133
133
 
134
134
  const firstInstallation = installInScratchHome(homeDirectory);
135
135
  assert.equal(firstInstallation.status, 0, firstInstallation.stdout + firstInstallation.stderr);
@@ -226,3 +226,156 @@ test('failed install leaves a directory at the config path unchanged', (context)
226
226
  assert.match(installation.stderr, /prior installation restored/);
227
227
  assert.equal(readFileSync(sentinelPath, 'utf8'), 'keep\n');
228
228
  });
229
+
230
+ test('the prompt the setting names survives a prune of the managed Claude files', (context) => {
231
+ const homeDirectory = makeScratchHome(context);
232
+ const configPath = join(homeDirectory, '.codex', CODEX_CONFIG_FILE_NAME);
233
+ const installation = installInScratchHome(homeDirectory);
234
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
235
+ rmSync(join(homeDirectory, '.claude', 'system-prompts'), { recursive: true, force: true });
236
+ const namedPath = JSON.parse(readFileSync(configPath, 'utf8').split('\n')[1].split(' = ')[1]);
237
+ assert.equal(existsSync(namedPath), true);
238
+ });
239
+
240
+ test('uninstall removes the Codex copy of the prompt', (context) => {
241
+ const homeDirectory = makeScratchHome(context);
242
+ const promptPath = join(homeDirectory, '.codex', 'codex-sol.md');
243
+ const installation = installInScratchHome(homeDirectory);
244
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
245
+ assert.equal(existsSync(promptPath), true);
246
+ const uninstallation = installInScratchHome(homeDirectory, ['--uninstall']);
247
+ assert.equal(uninstallation.status, 0, uninstallation.stdout + uninstallation.stderr);
248
+ assert.equal(existsSync(promptPath), false);
249
+ });
250
+
251
+ test('uninstall removes the Codex copy of the prompt when the user wrote their own setting', (context) => {
252
+ const homeDirectory = makeScratchHome(context);
253
+ const codexHome = join(homeDirectory, '.codex');
254
+ const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
255
+ const promptPath = join(codexHome, 'codex-sol.md');
256
+ const userText = 'model_instructions_file = "/mine.md"\n';
257
+ mkdirSync(codexHome, { recursive: true });
258
+ writeFileSync(configPath, userText);
259
+ const installation = installInScratchHome(homeDirectory);
260
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
261
+ assert.equal(existsSync(promptPath), true);
262
+ const uninstallation = installInScratchHome(homeDirectory, ['--uninstall']);
263
+ assert.equal(uninstallation.status, 0, uninstallation.stdout + uninstallation.stderr);
264
+ assert.equal(existsSync(promptPath), false);
265
+ assert.equal(readFileSync(configPath, 'utf8'), userText);
266
+ });
267
+
268
+
269
+ function makeLinkedCodexHomes(context) {
270
+ const homeDirectory = makeScratchHome(context);
271
+ const ownerHome = join(homeDirectory, '.codex');
272
+ const linkedHome = join(homeDirectory, '.codex-editor');
273
+ mkdirSync(ownerHome, { recursive: true });
274
+ mkdirSync(linkedHome, { recursive: true });
275
+ writeFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
276
+ try {
277
+ symlinkSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), join(linkedHome, CODEX_CONFIG_FILE_NAME), 'file');
278
+ } catch (error) {
279
+ if (error.code === 'EPERM') return null;
280
+ throw error;
281
+ }
282
+ return { homeDirectory, ownerHome, linkedHome };
283
+ }
284
+
285
+ test('a home whose config.toml links to another home names the prompt beside the real file', (context) => {
286
+ const homes = makeLinkedCodexHomes(context);
287
+ if (!homes) return context.skip('this account cannot create symlinks');
288
+ const { homeDirectory, ownerHome, linkedHome } = homes;
289
+ writeFileSync(join(linkedHome, 'codex-sol.md'), 'left by an earlier install\n');
290
+
291
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: ownerHome }).status, 0);
292
+ const linkedInstallation = installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome });
293
+
294
+ assert.equal(linkedInstallation.status, 0, linkedInstallation.stderr);
295
+ const configText = readFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'utf8');
296
+ assert.ok(configText.includes(`model_instructions_file = ${JSON.stringify(join(ownerHome, 'codex-sol.md'))}`), configText);
297
+ assert.equal(readFileSync(join(ownerHome, 'codex-sol.md'), 'utf8'), readFileSync(shippedPromptPath, 'utf8'));
298
+ assert.equal(existsSync(join(linkedHome, 'codex-sol.md')), false);
299
+ });
300
+
301
+ test('uninstall from a home whose config.toml is linked keeps the shared setting and prompt', (context) => {
302
+ const homes = makeLinkedCodexHomes(context);
303
+ if (!homes) return context.skip('this account cannot create symlinks');
304
+ const { homeDirectory, ownerHome, linkedHome } = homes;
305
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: ownerHome }).status, 0);
306
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome }).status, 0);
307
+
308
+ const uninstallation = installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: linkedHome });
309
+
310
+ assert.equal(uninstallation.status, 0, uninstallation.stderr);
311
+ const configText = readFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'utf8');
312
+ assert.ok(configText.includes(`model_instructions_file = ${JSON.stringify(join(ownerHome, 'codex-sol.md'))}`), configText);
313
+ assert.equal(existsSync(join(ownerHome, 'codex-sol.md')), true);
314
+ });
315
+
316
+ test('a home whose config.toml is a hard link to the main home\'s file names the prompt in the main home', (context) => {
317
+ const homeDirectory = makeScratchHome(context);
318
+ const mainHome = join(homeDirectory, '.codex');
319
+ const linkedHome = join(homeDirectory, '.codex-editor');
320
+ mkdirSync(mainHome, { recursive: true });
321
+ mkdirSync(linkedHome, { recursive: true });
322
+ writeFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
323
+ linkSync(join(mainHome, CODEX_CONFIG_FILE_NAME), join(linkedHome, CODEX_CONFIG_FILE_NAME));
324
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: mainHome }).status, 0);
325
+
326
+ const linkedInstallation = installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome });
327
+ assert.equal(linkedInstallation.status, 0, linkedInstallation.stderr);
328
+ const sharedLine = `model_instructions_file = ${JSON.stringify(join(mainHome, 'codex-sol.md'))}`;
329
+ assert.ok(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8').includes(sharedLine));
330
+ assert.equal(existsSync(join(linkedHome, 'codex-sol.md')), false);
331
+
332
+ const uninstallation = installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: linkedHome });
333
+ assert.equal(uninstallation.status, 0, uninstallation.stderr);
334
+ assert.ok(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8').includes(sharedLine));
335
+ assert.equal(existsSync(join(mainHome, 'codex-sol.md')), true);
336
+ });
337
+
338
+ test('a main home whose config.toml links into a dotfiles folder keeps the prompt in CODEX_HOME, and uninstall removes the line', (context) => {
339
+ const homeDirectory = makeScratchHome(context);
340
+ const mainHome = join(homeDirectory, '.codex');
341
+ const dotfilesDirectory = join(homeDirectory, 'dotfiles', 'codex');
342
+ mkdirSync(mainHome, { recursive: true });
343
+ mkdirSync(dotfilesDirectory, { recursive: true });
344
+ writeFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
345
+ try {
346
+ symlinkSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), join(mainHome, CODEX_CONFIG_FILE_NAME), 'file');
347
+ } catch (error) {
348
+ if (error.code === 'EPERM') return context.skip('this account cannot create symlinks');
349
+ throw error;
350
+ }
351
+
352
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: mainHome }).status, 0);
353
+ assert.ok(readFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'utf8')
354
+ .includes(`model_instructions_file = ${JSON.stringify(join(mainHome, 'codex-sol.md'))}`));
355
+ assert.equal(existsSync(join(dotfilesDirectory, 'codex-sol.md')), false);
356
+
357
+ assert.equal(installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: mainHome }).status, 0);
358
+ assert.equal(readFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'utf8'), 'approval_policy = "never"\n');
359
+ assert.equal(existsSync(join(mainHome, 'codex-sol.md')), false);
360
+ });
361
+
362
+ test('CODEX_HOME that reaches the main home through a folder link still uninstalls the line', (context) => {
363
+ const homeDirectory = makeScratchHome(context);
364
+ const mainHome = join(homeDirectory, '.codex');
365
+ const folderLink = join(homeDirectory, 'codex-home-link');
366
+ mkdirSync(mainHome, { recursive: true });
367
+ writeFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
368
+ try {
369
+ symlinkSync(mainHome, folderLink, 'junction');
370
+ } catch (error) {
371
+ if (error.code === 'EPERM') return context.skip('this account cannot create links');
372
+ throw error;
373
+ }
374
+
375
+ assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: folderLink }).status, 0);
376
+ assert.equal(existsSync(join(mainHome, 'codex-sol.md')), true);
377
+
378
+ assert.equal(installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: folderLink }).status, 0);
379
+ assert.equal(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8'), 'approval_policy = "never"\n');
380
+ assert.equal(existsSync(join(mainHome, 'codex-sol.md')), false);
381
+ });
@@ -0,0 +1,142 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ /** Env var that carries the folder path into the PowerShell probe, so no quoting reaches the command line. */
4
+ export const LOCKED_FOLDER_VARIABLE = 'CDE_LOCKED_FOLDER';
5
+ const LOCKER_PROBE_TIMEOUT_MILLISECONDS = 20000;
6
+ const LOCKED_FOLDER_PATTERN = /\b(EPERM|EBUSY|in use by another program|being used by another process)\b/i;
7
+ export const LOCKER_REASON_COMMAND_LINE = 'command-line';
8
+ export const LOCKER_REASON_OPEN_FILE = 'open-file';
9
+
10
+ /**
11
+ * PowerShell that lists the processes holding a folder.
12
+ *
13
+ * Two checks run: a process whose command line or executable path names a
14
+ * path inside the folder (an editor or shell opened on a file there), and a
15
+ * process that holds a file in the folder open, from the Windows Restart
16
+ * Manager. Each match prints one JSON line.
17
+ */
18
+ export const LOCKER_PROBE_SCRIPT = String.raw`
19
+ $ErrorActionPreference = 'SilentlyContinue'
20
+ $folder = [IO.Path]::GetFullPath($env:${LOCKED_FOLDER_VARIABLE}).TrimEnd('\')
21
+ $forms = @($folder, $folder.Replace('\', '/'))
22
+ foreach ($process in Get-CimInstance Win32_Process) {
23
+ $text = "$($process.CommandLine) $($process.ExecutablePath)"
24
+ foreach ($form in $forms) {
25
+ if ($text.IndexOf($form, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
26
+ [pscustomobject]@{ pid = [int]$process.ProcessId; name = $process.Name; reason = '${LOCKER_REASON_COMMAND_LINE}' } | ConvertTo-Json -Compress
27
+ break
28
+ }
29
+ }
30
+ }
31
+ Add-Type -Namespace Cde -Name RestartManager -MemberDefinition @'
32
+ [StructLayout(LayoutKind.Sequential)] public struct UniqueProcess { public int ProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME StartTime; }
33
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct ProcessInfo { public UniqueProcess Process; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string AppName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string ServiceName; public int ApplicationType; public uint AppStatus; public uint TSSessionId; [MarshalAs(UnmanagedType.Bool)] public bool Restartable; }
34
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] public static extern int RmStartSession(out uint handle, int flags, System.Text.StringBuilder key);
35
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] public static extern int RmRegisterResources(uint handle, uint fileCount, string[] files, uint appCount, IntPtr apps, uint serviceCount, string[] services);
36
+ [DllImport("rstrtmgr.dll")] public static extern int RmGetList(uint handle, out uint needed, ref uint count, [In, Out] ProcessInfo[] infos, ref uint reasons);
37
+ [DllImport("rstrtmgr.dll")] public static extern int RmEndSession(uint handle);
38
+ '@
39
+ $files = @(Get-ChildItem -LiteralPath $folder -Recurse -File -Force | Select-Object -First 2000 -ExpandProperty FullName)
40
+ if ($files.Count -gt 0) {
41
+ $handle = [uint32]0
42
+ if ([Cde.RestartManager]::RmStartSession([ref]$handle, 0, (New-Object Text.StringBuilder 64)) -eq 0) {
43
+ try {
44
+ if ([Cde.RestartManager]::RmRegisterResources($handle, [uint32]$files.Count, [string[]]$files, 0, [IntPtr]::Zero, 0, $null) -eq 0) {
45
+ $needed = [uint32]0; $count = [uint32]64; $reasons = [uint32]0
46
+ $infos = New-Object Cde.RestartManager+ProcessInfo[] 64
47
+ if ([Cde.RestartManager]::RmGetList($handle, [ref]$needed, [ref]$count, $infos, [ref]$reasons) -eq 0) {
48
+ for ($index = 0; $index -lt $count; $index++) {
49
+ [pscustomobject]@{ pid = $infos[$index].Process.ProcessId; name = $infos[$index].AppName; reason = '${LOCKER_REASON_OPEN_FILE}' } | ConvertTo-Json -Compress
50
+ }
51
+ }
52
+ }
53
+ } finally { [void][Cde.RestartManager]::RmEndSession($handle) }
54
+ }
55
+ }
56
+ `;
57
+
58
+ /**
59
+ * True when a host command's error text says a folder could not be replaced because it is in use.
60
+ *
61
+ * @param {string} errorText The command's error output.
62
+ * @returns {boolean}
63
+ */
64
+ export function namesALockedFolder(errorText) {
65
+ return LOCKED_FOLDER_PATTERN.test(errorText ?? '');
66
+ }
67
+
68
+ /**
69
+ * Parse the probe's JSON lines into one entry per process.
70
+ *
71
+ * @param {string} probeOutput Standard output of the PowerShell probe.
72
+ * @param {number} ownProcessId This installer's process id, which never counts as a locker.
73
+ * @returns {{pid: number, name: string, reason: string}[]}
74
+ */
75
+ export function parseLockerLines(probeOutput, ownProcessId = process.pid) {
76
+ const lockersByProcessId = new Map();
77
+ for (const eachLine of (probeOutput ?? '').split(/\r?\n/)) {
78
+ const trimmedLine = eachLine.trim();
79
+ if (!trimmedLine.startsWith('{')) continue;
80
+ let locker;
81
+ try {
82
+ locker = JSON.parse(trimmedLine);
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (!Number.isInteger(locker.pid) || locker.pid === ownProcessId) continue;
87
+ if (!lockersByProcessId.has(locker.pid)) lockersByProcessId.set(locker.pid, locker);
88
+ }
89
+ return [...lockersByProcessId.values()];
90
+ }
91
+
92
+ /**
93
+ * List the running processes that hold a folder, on Windows.
94
+ *
95
+ * @param {string} folderPath The folder a host could not replace.
96
+ * @param {object} [dependencies] Seams for tests.
97
+ * @param {string} [dependencies.platform] The Node platform name.
98
+ * @param {(folderPath: string) => string|null} [dependencies.runProbe] Returns the probe's standard output, or null when the probe could not start or timed out.
99
+ * @returns {{pid: number, name: string, reason: string}[]|null} The holders, an empty list when the probe ran and found none, and null when nothing was searched: another platform, or a probe that could not start or timed out.
100
+ */
101
+ export function findFolderLockers(folderPath, dependencies = {}) {
102
+ const platform = dependencies.platform ?? process.platform;
103
+ if (platform !== 'win32' || !folderPath) return null;
104
+ const runProbe = dependencies.runProbe ?? runLockerProbe;
105
+ const probeOutput = runProbe(folderPath);
106
+ return probeOutput === null ? null : parseLockerLines(probeOutput);
107
+ }
108
+
109
+ function runLockerProbe(folderPath) {
110
+ const encodedScript = Buffer.from(LOCKER_PROBE_SCRIPT, 'utf16le').toString('base64');
111
+ const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodedScript], {
112
+ encoding: 'utf8',
113
+ env: { ...process.env, [LOCKED_FOLDER_VARIABLE]: folderPath },
114
+ timeout: LOCKER_PROBE_TIMEOUT_MILLISECONDS,
115
+ windowsHide: true,
116
+ });
117
+ if (probe.error) return null;
118
+ return probe.stdout ?? '';
119
+ }
120
+
121
+ function describeLocker(locker) {
122
+ const how = locker.reason === LOCKER_REASON_OPEN_FILE
123
+ ? 'has a file in it open'
124
+ : 'was started on a path inside it';
125
+ return `${locker.name} (PID ${locker.pid}, ${how})`;
126
+ }
127
+
128
+ /**
129
+ * Tell the user exactly what to close so a locked plugin folder can be replaced.
130
+ *
131
+ * @param {string} folderPath The folder the host could not replace.
132
+ * @param {{pid: number, name: string, reason: string}[]} allLockers What holds it.
133
+ * @returns {string}
134
+ */
135
+ export function lockedFolderAdvice(folderPath, allLockers) {
136
+ if (allLockers.length === 0) {
137
+ return `No running process names ${folderPath} or holds a file in it open, so a window or terminal`
138
+ + ' whose working folder is inside it is the likely holder. Close it and run the install again.';
139
+ }
140
+ const verb = allLockers.length === 1 ? 'holds' : 'hold';
141
+ return `Close ${allLockers.map(describeLocker).join(', ')}, which ${verb} ${folderPath}, and run the install again.`;
142
+ }
@@ -0,0 +1,55 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import {
4
+ findFolderLockers,
5
+ LOCKER_REASON_COMMAND_LINE,
6
+ LOCKER_REASON_OPEN_FILE,
7
+ lockedFolderAdvice,
8
+ namesALockedFolder,
9
+ parseLockerLines,
10
+ } from './folder-lockers.mjs';
11
+
12
+ test('a host error about a folder in use names a locked folder', () => {
13
+ assert.equal(namesALockedFolder('could not be replaced: it is in use by another program (EPERM)'), true);
14
+ assert.equal(namesALockedFolder('EBUSY: resource busy or locked'), true);
15
+ assert.equal(namesALockedFolder('Failed to parse marketplace file'), false);
16
+ });
17
+
18
+ test('probe lines become one entry per process, and this process never counts', () => {
19
+ const probeOutput = [
20
+ '{"pid":19056,"name":"Cursor.exe","reason":"command-line"}',
21
+ '{"pid":19056,"name":"Cursor.exe","reason":"open-file"}',
22
+ 'WARNING: noise',
23
+ '{"pid":4242,"name":"pwsh.exe","reason":"open-file"}',
24
+ '{"pid":7,"name":"node.exe","reason":"command-line"}',
25
+ ].join('\r\n');
26
+ assert.deepEqual(parseLockerLines(probeOutput, 7), [
27
+ { pid: 19056, name: 'Cursor.exe', reason: LOCKER_REASON_COMMAND_LINE },
28
+ { pid: 4242, name: 'pwsh.exe', reason: LOCKER_REASON_OPEN_FILE },
29
+ ]);
30
+ });
31
+
32
+ test('only Windows runs the probe', () => {
33
+ let probedFolder = null;
34
+ const runProbe = folderPath => {
35
+ probedFolder = folderPath;
36
+ return '{"pid":5,"name":"Cursor.exe","reason":"command-line"}';
37
+ };
38
+ assert.equal(findFolderLockers('/cache/pstack', { platform: 'linux', runProbe }), null);
39
+ assert.equal(probedFolder, null);
40
+ assert.equal(findFolderLockers('C:/cache/pstack', { platform: 'win32', runProbe }).length, 1);
41
+ assert.equal(probedFolder, 'C:/cache/pstack');
42
+ });
43
+
44
+ test('the advice names each process to close, or says what else holds a folder', () => {
45
+ assert.equal(
46
+ lockedFolderAdvice('C:/cache/pstack', [{ pid: 19056, name: 'Cursor.exe', reason: LOCKER_REASON_COMMAND_LINE }]),
47
+ 'Close Cursor.exe (PID 19056, was started on a path inside it), which holds C:/cache/pstack, and run the install again.',
48
+ );
49
+ assert.match(lockedFolderAdvice('C:/cache/pstack', []), /working folder is inside it/);
50
+ });
51
+
52
+ test('a probe that did not finish searched nothing, and a probe that found no holder is an empty list', () => {
53
+ assert.equal(findFolderLockers('C:/cache/pstack', { platform: 'win32', runProbe: () => null }), null);
54
+ assert.deepEqual(findFolderLockers('C:/cache/pstack', { platform: 'win32', runProbe: () => '' }), []);
55
+ });
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Return the folder a global `npm install -g` of the package lives in, or null when the prefix is unknown.
6
+ *
7
+ * npm passes its global prefix to the programs it starts as
8
+ * `npm_config_global_prefix`. On Windows the default prefix is `%APPDATA%\npm`.
9
+ *
10
+ * @param {string} packageName
11
+ * @param {Record<string, string|undefined>} environment
12
+ * @param {string} platform The Node platform name.
13
+ * @returns {string|null}
14
+ */
15
+ export function globalPackageDirectory(packageName, environment, platform) {
16
+ const windowsDefaultPrefix = platform === 'win32' && environment.APPDATA ? join(environment.APPDATA, 'npm') : null;
17
+ const prefix = environment.npm_config_global_prefix || environment.npm_config_prefix || windowsDefaultPrefix;
18
+ if (!prefix) return null;
19
+ return platform === 'win32'
20
+ ? join(prefix, 'node_modules', packageName)
21
+ : join(prefix, 'lib', 'node_modules', packageName);
22
+ }
23
+
24
+ function samePath(firstPath, secondPath) {
25
+ try {
26
+ return realpathSync(firstPath).toLowerCase() === realpathSync(secondPath).toLowerCase();
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Find a global install of the package at another version than the one running.
34
+ *
35
+ * `npx <package>` with no version runs a global copy when one is on PATH, so
36
+ * an old global copy runs instead of the newest release, and its update pass
37
+ * prunes files that newer installs left for Codex and Cursor.
38
+ *
39
+ * @param {object} options
40
+ * @param {string} options.packageName
41
+ * @param {string} options.packageRoot The folder of the copy that is running.
42
+ * @param {string} options.packageVersion The running version.
43
+ * @param {Record<string, string|undefined>} [options.environment]
44
+ * @param {string} [options.platform]
45
+ * @returns {{directory: string, version: string}|null}
46
+ */
47
+ export function findShadowingGlobalInstall(options) {
48
+ const environment = options.environment ?? process.env;
49
+ const directory = globalPackageDirectory(options.packageName, environment, options.platform ?? process.platform);
50
+ if (!directory) return null;
51
+ const manifestPath = join(directory, 'package.json');
52
+ if (!existsSync(manifestPath) || samePath(directory, options.packageRoot)) return null;
53
+ let version;
54
+ try {
55
+ version = JSON.parse(readFileSync(manifestPath, 'utf8')).version;
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (!version || version === options.packageVersion) return null;
60
+ return { directory, version };
61
+ }
62
+
63
+ /**
64
+ * Say what the global copy does and the exact commands that fix it.
65
+ *
66
+ * @param {string} packageName
67
+ * @param {{directory: string, version: string}} shadow
68
+ * @param {string} packageVersion The running version.
69
+ * @returns {string}
70
+ */
71
+ export function shadowingGlobalAdvice(packageName, shadow, packageVersion) {
72
+ return `A global ${packageName} ${shadow.version} is installed at ${shadow.directory}. `
73
+ + `\`npx ${packageName}\` without a version runs that copy instead of ${packageVersion}. `
74
+ + `Remove it with \`npm uninstall -g ${packageName}\`, or update it with \`npm install -g ${packageName}@latest\`.`;
75
+ }
@@ -0,0 +1,56 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { findShadowingGlobalInstall, globalPackageDirectory, shadowingGlobalAdvice } from './global-install-shadow.mjs';
7
+
8
+ function makeScratch(context) {
9
+ const scratch = mkdtempSync(join(tmpdir(), 'cde-shadow-'));
10
+ context.after(() => rmSync(scratch, { recursive: true, force: true }));
11
+ return scratch;
12
+ }
13
+
14
+ function writeGlobalCopy(prefix, version) {
15
+ const directory = join(prefix, 'node_modules', 'claude-dev-env');
16
+ mkdirSync(directory, { recursive: true });
17
+ writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'claude-dev-env', version }));
18
+ return directory;
19
+ }
20
+
21
+ test('the global folder comes from npm, or the Windows default prefix', () => {
22
+ assert.equal(
23
+ globalPackageDirectory('claude-dev-env', { npm_config_global_prefix: '/usr/local' }, 'linux'),
24
+ join('/usr/local', 'lib', 'node_modules', 'claude-dev-env'),
25
+ );
26
+ assert.equal(
27
+ globalPackageDirectory('claude-dev-env', { APPDATA: 'C:/Roaming' }, 'win32'),
28
+ join('C:/Roaming', 'npm', 'node_modules', 'claude-dev-env'),
29
+ );
30
+ assert.equal(globalPackageDirectory('claude-dev-env', {}, 'linux'), null);
31
+ });
32
+
33
+ test('an older global copy is found and named with the commands that fix it', (context) => {
34
+ const scratch = makeScratch(context);
35
+ const directory = writeGlobalCopy(join(scratch, 'npm'), '8.35.1');
36
+ const shadow = findShadowingGlobalInstall({
37
+ packageName: 'claude-dev-env',
38
+ packageRoot: join(scratch, 'npx-copy'),
39
+ packageVersion: '8.89.0',
40
+ environment: { APPDATA: scratch },
41
+ platform: 'win32',
42
+ });
43
+ assert.deepEqual(shadow, { directory, version: '8.35.1' });
44
+ const advice = shadowingGlobalAdvice('claude-dev-env', shadow, '8.89.0');
45
+ assert.match(advice, /global claude-dev-env 8\.35\.1/);
46
+ assert.match(advice, /npm uninstall -g claude-dev-env/);
47
+ });
48
+
49
+ test('a global copy at the running version, or the running copy itself, is not reported', (context) => {
50
+ const scratch = makeScratch(context);
51
+ const directory = writeGlobalCopy(join(scratch, 'npm'), '8.89.0');
52
+ const options = { packageName: 'claude-dev-env', environment: { APPDATA: scratch }, platform: 'win32' };
53
+ assert.equal(findShadowingGlobalInstall({ ...options, packageRoot: join(scratch, 'x'), packageVersion: '8.89.0' }), null);
54
+ assert.equal(findShadowingGlobalInstall({ ...options, packageRoot: directory, packageVersion: '9.0.0' }), null);
55
+ assert.equal(findShadowingGlobalInstall({ ...options, environment: {}, packageRoot: directory, packageVersion: '1' }), null);
56
+ });