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.
- package/bin/codex-trimmed-sol.mjs +100 -3
- package/bin/codex-trimmed-sol.test.mjs +155 -2
- package/bin/folder-lockers.mjs +142 -0
- package/bin/folder-lockers.test.mjs +55 -0
- package/bin/global-install-shadow.mjs +75 -0
- package/bin/global-install-shadow.test.mjs +56 -0
- package/bin/install-deepstack-plugin.mjs +45 -16
- package/bin/install-deepstack-plugin.test.mjs +65 -1
- package/bin/install-pstack-plugin.mjs +20 -4
- package/bin/install-pstack-plugin.test.mjs +53 -0
- package/bin/install.mjs +79 -6
- package/bin/install.test.mjs +93 -0
- package/bin/profile-stop-gate.mjs +193 -0
- package/bin/profile-stop-gate.test.mjs +124 -0
- package/docs/agents-guidance/asks.md +2 -1
- package/docs/codex-accounts.md +14 -2
- package/docs/decisions/deepstack-gate.md +6 -2
- package/hooks/features/session-context.md +1 -1
- package/hooks/hooks_constants/deepen_default_rewrite_constants.py +39 -0
- package/hooks/session/deepen_default_rewrite.py +128 -0
- package/hooks/session/deepen_first_prompt.py +3 -15
- package/hooks/session/test_deepen_default_rewrite.py +90 -0
- package/hooks/session/test_deepen_first_prompt.py +12 -1
- package/package.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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, '.
|
|
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
|
+
});
|