claude-dev-env 8.88.1 → 8.88.2

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,4 +1,4 @@
1
- import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  export const SOL_PROMPT_DIRECTORY_NAME = 'system-prompts';
@@ -53,6 +53,38 @@ function readCodexConfig(configPath) {
53
53
  return { isReadable: true, currentText: readFileSync(configPath, 'utf8') };
54
54
  }
55
55
 
56
+ /**
57
+ * Return where the installer keeps the Codex copy of the trimmed Sol prompt.
58
+ *
59
+ * The copy lives in CODEX_HOME beside config.toml, so the file the setting
60
+ * names stays put when any claude-dev-env version, older ones included,
61
+ * prunes or rewrites the managed files under ~/.claude. A missing file makes
62
+ * every Codex command fail before it starts.
63
+ *
64
+ * @param {string} codexHome
65
+ * @returns {string}
66
+ */
67
+ export function codexSolPromptPath(codexHome) {
68
+ return join(codexHome, SOL_PROMPT_FILE_NAME);
69
+ }
70
+
71
+ /**
72
+ * Copy the trimmed Sol prompt into CODEX_HOME.
73
+ *
74
+ * @param {string} codexHome
75
+ * @param {string} sourcePromptPath The prompt file the package ships.
76
+ * @returns {string|null} The Codex copy's path when this call wrote it, and null when it was already current.
77
+ */
78
+ export function installCodexSolPrompt(codexHome, sourcePromptPath) {
79
+ const promptPath = codexSolPromptPath(codexHome);
80
+ const promptText = readFileSync(sourcePromptPath, 'utf8');
81
+ const promptEntry = lstatSync(promptPath, { throwIfNoEntry: false });
82
+ if (promptEntry?.isFile() && readFileSync(promptPath, 'utf8') === promptText) return null;
83
+ mkdirSync(codexHome, { recursive: true });
84
+ writeFileSync(promptPath, promptText, 'utf8');
85
+ return promptPath;
86
+ }
87
+
56
88
  export function codexSolSettingSnapshotPaths(codexHome) {
57
89
  const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
58
90
  const { isReadable, currentText } = readCodexConfig(configPath);
@@ -88,12 +120,15 @@ export function writeCodexSolSetting(codexHome, instructionsPath) {
88
120
  * Remove the package's `model_instructions_file` line from CODEX_HOME/config.toml.
89
121
  *
90
122
  * The rest of the file stays as the user wrote it. A file that held only the
91
- * package line is removed.
123
+ * package line is removed. The Codex copy of the prompt is removed in every
124
+ * case, because install writes it even when the user's own setting kept the
125
+ * package line out.
92
126
  *
93
127
  * @param {string} codexHome
94
128
  * @returns {string|null} The config path when it changed, or null when nothing was removed.
95
129
  */
96
130
  export function removeCodexSolSetting(codexHome) {
131
+ rmSync(codexSolPromptPath(codexHome), { force: true });
97
132
  const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
98
133
  const { isReadable, currentText } = readCodexConfig(configPath);
99
134
  if (!isReadable || currentText === null) return null;
@@ -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,41 @@ 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
+ });
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -23,6 +23,9 @@ export const DEEPSTACK_PLUGIN_OPT_OUT_FLAG = '--no-deepstack';
23
23
  export const DEEPSTACK_PLUGIN_OPT_OUT_VARIABLE = 'CDE_INSTALL_DEEPSTACK';
24
24
  export const DEEPSTACK_SOURCE_VARIABLE = 'CDE_DEEPSTACK_SOURCE';
25
25
  export const DEEPSTACK_CURSOR_DIRECTORY_NAME = 'plugins/deepstack';
26
+ /** Name prefix of the temporary DeepStack clone a full install makes when no local tree exists. */
27
+ export const DEEPSTACK_CLONE_PREFIX = 'deepstack-';
28
+ const CLONE_REMOVAL_RETRIES = 3;
26
29
  export const DEEPSTACK_SKILL_RELATIVE_PATH = 'skills/deepstack-mode/SKILL.md';
27
30
  export const DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH = 'hooks/session-start.sh';
28
31
  export const DEEPSTACK_SESSION_POWERSHELL_RELATIVE_PATH = 'hooks/session-start.ps1';
@@ -266,7 +269,9 @@ export function seedCodexDeepStackModels(codexHome) {
266
269
  * @param {string} [options.sourceDirectory] A local plugin tree for the Cursor copy.
267
270
  * @param {string[]} [options.argumentList] Command-line arguments, for the opt-out flag.
268
271
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
269
- * @param {object} [dependencies] Seams for the marketplace command runner.
272
+ * @param {boolean} [options.allowClone] False stops the clone fallback.
273
+ * @param {string} [options.cloneParentDirectory] Where the temporary clone goes. Defaults to the system temp folder.
274
+ * @param {object} [dependencies] Seams for the marketplace command runner and the clone.
270
275
  * @returns {{status: string, hosts: object[], cursor: object, warning: string|null}} The outcome.
271
276
  */
272
277
  export function installDeepStackForHosts(options = {}, dependencies = {}) {
@@ -290,16 +295,25 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
290
295
  environment,
291
296
  sourceDirectory: options.sourceDirectory,
292
297
  });
293
- if (!sourceDirectory && options.allowClone !== false) {
294
- sourceDirectory = cloneDeepStackRepository(mkdtempSync(join(tmpdir(), 'deepstack-')));
298
+ const cloneDirectory = !sourceDirectory && options.allowClone !== false
299
+ ? mkdtempSync(join(options.cloneParentDirectory ?? tmpdir(), DEEPSTACK_CLONE_PREFIX))
300
+ : null;
301
+ let cursor;
302
+ try {
303
+ if (cloneDirectory) {
304
+ const cloneRepository = dependencies.cloneRepository ?? cloneDeepStackRepository;
305
+ sourceDirectory = cloneRepository(cloneDirectory);
306
+ }
307
+ cursor = installDeepStackCursor({
308
+ cursorDirectory: options.cursorDirectory,
309
+ pluginSourceDirectory: sourceDirectory,
310
+ firstTaskHookPath: options.firstTaskHookPath,
311
+ pythonCommand: options.pythonCommand,
312
+ formatPathArgument: options.formatPathArgument,
313
+ });
314
+ } finally {
315
+ if (cloneDirectory) removeDeepStackClone(cloneDirectory);
295
316
  }
296
- const cursor = installDeepStackCursor({
297
- cursorDirectory: options.cursorDirectory,
298
- pluginSourceDirectory: sourceDirectory,
299
- firstTaskHookPath: options.firstTaskHookPath,
300
- pythonCommand: options.pythonCommand,
301
- formatPathArgument: options.formatPathArgument,
302
- });
303
317
  if (marketplace.hosts.some(host => host.host === 'codex' && host.status === 'installed') && options.codexHome) {
304
318
  seedCodexDeepStackModels(options.codexHome);
305
319
  }
@@ -314,14 +328,20 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
314
328
  }
315
329
 
316
330
  /**
317
- * Clone jl-cmd-projects/DeepStack when no local tree is available.
331
+ * Remove the temporary DeepStack clone once the Cursor copy is written.
318
332
  *
319
- * The installer calls this only from a full install that has no source
320
- * directory and no marketplace cache. Tests pass a source directory instead.
333
+ * Nothing reads the clone after the install: the Cursor hooks point at the
334
+ * copy under the Cursor home, and Claude and Codex read their own marketplace
335
+ * caches. Git writes its pack files read-only, which the forced removal
336
+ * handles on Windows too.
321
337
  *
322
- * @param {string} destination An empty directory for the clone.
323
- * @returns {string|null} The plugin directory inside the clone.
338
+ * @param {string} cloneDirectory The directory the clone was made in.
339
+ * @returns {void}
324
340
  */
341
+ export function removeDeepStackClone(cloneDirectory) {
342
+ rmSync(cloneDirectory, { recursive: true, force: true, maxRetries: CLONE_REMOVAL_RETRIES });
343
+ }
344
+
325
345
  /**
326
346
  * Return the git clone URL for the DeepStack marketplace repository.
327
347
  *
@@ -331,6 +351,15 @@ export function deepStackCloneUrl() {
331
351
  return ['https:', '', 'github.com', `${DEEPSTACK_MARKETPLACE_REPOSITORY}.git`].join('/');
332
352
  }
333
353
 
354
+ /**
355
+ * Clone jl-cmd-projects/DeepStack when no local tree is available.
356
+ *
357
+ * The installer calls this only from a full install that has no source
358
+ * directory and no marketplace cache. Tests pass a source directory instead.
359
+ *
360
+ * @param {string} destination An empty directory for the clone.
361
+ * @returns {string|null} The plugin directory inside the clone.
362
+ */
334
363
  export function cloneDeepStackRepository(destination) {
335
364
  const outcome = spawnSync('git', [
336
365
  'clone',
@@ -1,10 +1,11 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  DEEPSTACK_MARKETPLACE_REPOSITORY,
8
+ DEEPSTACK_SKILL_RELATIVE_PATH,
8
9
  DEEPSTACK_PLUGIN_IDENTIFIER,
9
10
  DEEPSTACK_PLUGIN_SPEC,
10
11
  deepStackCloneUrl,
@@ -136,3 +137,66 @@ test('--no-deepstack and CDE_INSTALL_DEEPSTACK=0 skip the install', () => {
136
137
  assert.equal(skipped.status, 'skipped');
137
138
  assert.equal(skipped.cursor.status, 'skipped');
138
139
  });
140
+
141
+ const absentHostRunner = () => ({ status: null, stderr: '', error: { code: 'ENOENT' } });
142
+
143
+ function writeClonedPlugin(cloneDirectory) {
144
+ const pluginDirectory = join(cloneDirectory, 'plugins', 'deepstack');
145
+ mkdirSync(join(pluginDirectory, 'skills', 'deepstack-mode'), { recursive: true });
146
+ writeFileSync(join(pluginDirectory, DEEPSTACK_SKILL_RELATIVE_PATH), '# DeepStack\n');
147
+ mkdirSync(join(cloneDirectory, '.git', 'objects', 'pack'), { recursive: true });
148
+ const packPath = join(cloneDirectory, '.git', 'objects', 'pack', 'pack-1.pack');
149
+ writeFileSync(packPath, 'pack');
150
+ chmodSync(packPath, 0o444);
151
+ return pluginDirectory;
152
+ }
153
+
154
+ test('the temporary clone is removed once the Cursor copy is written', () => {
155
+ const root = mkdtempSync(join(tmpdir(), 'deepstack-clone-'));
156
+ try {
157
+ const cloneParentDirectory = join(root, 'temp');
158
+ mkdirSync(cloneParentDirectory);
159
+ const cursorDirectory = join(root, 'cursor');
160
+ let clonedInto = null;
161
+ const outcome = installDeepStackForHosts({
162
+ claudeRoot: join(root, 'claude'),
163
+ codexHome: join(root, 'codex'),
164
+ cursorDirectory,
165
+ firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
166
+ environment: {},
167
+ cloneParentDirectory,
168
+ }, {
169
+ runCommand: absentHostRunner,
170
+ cloneRepository: cloneDirectory => {
171
+ clonedInto = cloneDirectory;
172
+ return writeClonedPlugin(cloneDirectory);
173
+ },
174
+ });
175
+ assert.equal(outcome.cursor.status, 'installed');
176
+ assert.ok(clonedInto.startsWith(cloneParentDirectory));
177
+ assert.ok(existsSync(join(cursorDirectory, 'plugins', 'deepstack', DEEPSTACK_SKILL_RELATIVE_PATH)));
178
+ assert.deepEqual(readdirSync(cloneParentDirectory), []);
179
+ } finally {
180
+ rmSync(root, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('a failed clone leaves no temporary folder behind', () => {
185
+ const root = mkdtempSync(join(tmpdir(), 'deepstack-clone-'));
186
+ try {
187
+ const cloneParentDirectory = join(root, 'temp');
188
+ mkdirSync(cloneParentDirectory);
189
+ const outcome = installDeepStackForHosts({
190
+ claudeRoot: join(root, 'claude'),
191
+ codexHome: join(root, 'codex'),
192
+ cursorDirectory: join(root, 'cursor'),
193
+ firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
194
+ environment: {},
195
+ cloneParentDirectory,
196
+ }, { runCommand: absentHostRunner, cloneRepository: () => null });
197
+ assert.equal(outcome.cursor.status, 'failed');
198
+ assert.deepEqual(readdirSync(cloneParentDirectory), []);
199
+ } finally {
200
+ rmSync(root, { recursive: true, force: true });
201
+ }
202
+ });