Skip to content

Commit 632da1b

Browse files
authored
fix: improve npx sandbox detection (#62)
* feat: Implement npx sandbox detection and enhance file download logic - Added `detect-npx.mjs` to identify if the CLI is running in an npx sandbox. - Updated `download-files.mjs` to utilize the new detection logic for improved path resolution. - Introduced `find-package-root.mjs` to locate the package root based on `package.json` and `.cursor` directory. - Adjusted `package-lock.json` to reflect changes in file paths and permissions. * feat: Enhance interactive mode and default output path - Introduced `findPackageRoot` to dynamically locate the package root for rules. - Updated `scanAvailableRules` to use the resolved source rules path. - Set a default output directory to the user's Downloads folder for improved usability. * refactor: Update interactive menu and rule scanning logic - Replaced `fs/promises` with synchronous `fs` methods for improved performance in the interactive menu. - Enhanced `scanAvailableRules` to accept a base path parameter, allowing for more flexible rule path resolution. - Updated console output methods from `console.log` to `console.info` for better clarity in the interactive menu. - Added debug logging in `validateDirname` to assist with path validation troubleshooting.
1 parent 12f9161 commit 632da1b

8 files changed

Lines changed: 132 additions & 33 deletions

File tree

cli/commands.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use strict'
22
import * as fs from 'node:fs/promises';
3+
import { homedir } from 'node:os';
34
import { dirname, resolve } from 'node:path';
45
import { fileURLToPath } from 'node:url';
56
import { downloadFiles, downloadSelectedFiles } from './utils/download-files.mjs';
7+
import { findPackageRoot } from './utils/find-package-root.mjs';
68
import { interactiveCategorySelection, prepareMenu, scanAvailableRules, selectRules } from './utils/interactive-menu.mjs';
79
import { validateDirname } from './utils/validate-dirname.mjs';
810

911
export const __dirname = dirname(fileURLToPath(import.meta.url));
1012
export const packageJsonPath = resolve(__dirname, '..', 'package.json');
1113
export const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
14+
const defaultOutput = resolve(homedir(), 'Downloads', '.cursor');
1215

1316
/** @returns {void} */
1417
export const help = () => {
@@ -44,7 +47,9 @@ export const version = () => console.log(`${packageJson?.name} v${packageJson?.v
4447
*/
4548
export const interactiveMode = async (values) => {
4649
console.log('🎯 Starting interactive mode...');
47-
const rules = await scanAvailableRules();
50+
const packageRoot = findPackageRoot(__dirname, '@usrrname/cursorrules');
51+
const sourceRulesBasePath = resolve(packageRoot, '.cursor', 'rules');
52+
const rules = await scanAvailableRules(sourceRulesBasePath);
4853
// Prepare persistent selection state for each category
4954
const categories = Object.keys(rules).filter(cat => rules[cat].length > 0);
5055

@@ -63,7 +68,7 @@ export const interactiveMode = async (values) => {
6368
const allSelectedRules = Object.values(persistentSelections)
6469
.flat()
6570
.filter(rule => rule.selected);
66-
const outputDir = values?.output?.toString() ?? `${process.cwd()}/.cursor/`;
71+
const outputDir = values?.output?.toString() ?? defaultOutput;
6772
if (allSelectedRules.length > 0)
6873
return await downloadSelectedFiles(outputDir, allSelectedRules);
6974
else

cli/index.mjs

100644100755
File mode changed.

cli/utils/detect-npx.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { dirname } from 'node:path';
2+
import { fileURLToPath } from 'node:url';
3+
4+
const __dirname = dirname(fileURLToPath(import.meta.url));
5+
6+
export function detectNpxSandbox() {
7+
// Check environment variables
8+
const npmPrefix = process.env.npm_config_prefix || '';
9+
const npmCache = process.env.npm_config_cache || '';
10+
const tempDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '';
11+
12+
// Analyze current file path
13+
const currentPath = __dirname;
14+
15+
//Check for npx-specific patterns in the path
16+
const npxPatterns = [
17+
'_npx',
18+
'npm-cache',
19+
'_cacache',
20+
/npm-\d+-[a-f0-9]+/, // npm-<pid>-<random>
21+
/tmp.*npm/, // temp directories with npm
22+
];
23+
24+
// Check if we're in node_modules of a temp-like directory
25+
const isInNodeModules = currentPath.includes('node_modules');
26+
const pathSegments = currentPath.split(/[/\\]/);
27+
28+
// Look for npx indicators
29+
const hasNpxIndicators = npxPatterns.some(pattern => {
30+
if (typeof pattern === 'string') {
31+
return currentPath.includes(pattern) || npmPrefix.includes(pattern) || npmCache.includes(pattern);
32+
} else {
33+
return pattern.test(currentPath) || pattern.test(npmPrefix) || pattern.test(npmCache);
34+
}
35+
});
36+
37+
// Check for temporary directory characteristics
38+
const isTempLike = pathSegments.some(segment =>
39+
/^(tmp|temp|cache)$/i.test(segment) ||
40+
/^[a-f0-9]{8,}$/.test(segment) || // long hex strings
41+
/^\d+$/.test(segment) // numeric directories
42+
);
43+
44+
return {
45+
isNpxSandbox: hasNpxIndicators || (isInNodeModules && isTempLike),
46+
currentPath,
47+
npmPrefix,
48+
npmCache,
49+
indicators: {
50+
hasNpxIndicators,
51+
isInNodeModules,
52+
isTempLike,
53+
pathSegments
54+
}
55+
};
56+
}

cli/utils/download-files.mjs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,29 @@
11
import { copyFile, cp, mkdir } from 'node:fs/promises';
2-
import { dirname, join, sep as pathSep, resolve } from 'node:path';
2+
import { dirname, join, resolve } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import { help } from '../commands.mjs';
5+
import { detectNpxSandbox } from './detect-npx.mjs';
56
import { findFolderUp } from './find-folder-up.mjs';
7+
import { findPackageRoot } from './find-package-root.mjs';
68
import { validateDirname } from './validate-dirname.mjs';
79

8-
export const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const detection = detectNpxSandbox();
11+
console.log('🔍 NPX Detection Results:', detection);
912

10-
// Detect if we're running inside an npx sandbox. npm sets npm_config_prefix to that temp dir.
11-
const npmPrefix = process.env.npm_config_prefix?.toString() || '';
12-
const isNpxSandbox = npmPrefix.includes(`${pathSep}_npx${pathSep}`);
13+
const __dirname = dirname(fileURLToPath(import.meta.url));
14+
console.log('🔍 __dirname:', __dirname);
1315
/** @type {string} */
1416
let sourceRulesBasePath;
1517

16-
console.log('DEBUG: npmPrefix:', npmPrefix);
17-
console.log('DEBUG: pathSep:', pathSep);
18-
console.log('DEBUG: isNpxSandbox check string:', `${pathSep}_npx${pathSep}`);
19-
console.log('DEBUG: isNpxSandbox:', isNpxSandbox);
20-
21-
if (isNpxSandbox) {
22-
// inside npx → rules live alongside package contents
23-
sourceRulesBasePath = resolve(npmPrefix, 'rules');
18+
if (detection.isNpxSandbox) {
19+
const packageRoot = findPackageRoot(__dirname, '@usrrname/cursorrules');
20+
console.log('DEBUG: packageRoot (npx branch):', packageRoot);
21+
sourceRulesBasePath = resolve(packageRoot, '.cursor', 'rules');
22+
console.log('DEBUG: sourceRulesBasePath:', sourceRulesBasePath);
2423
}
2524

25+
26+
// only for local development and testing
2627
if (process.env.CI || ['development', 'test'].includes(process.env.NODE_ENV ?? '')) {
2728
// running inside repo / globally installed copy → locate nearest .cursor
2829
const found = await findFolderUp('.cursor', process.cwd())

cli/utils/find-package-root.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { readFileSync, statSync } from "node:fs";
2+
import { dirname, resolve } from "node:path";
3+
/**
4+
* @param {string} startPath
5+
* @param {string} packageName
6+
* @returns {string}
7+
*/
8+
export function findPackageRoot(startPath, packageName) {
9+
let currentPath = startPath;
10+
11+
while (currentPath !== dirname(currentPath)) {
12+
// Look for package.json or your package-specific markers
13+
try {
14+
const packageJsonPath = resolve(currentPath, 'package.json');
15+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
16+
17+
// Verify package
18+
if (packageJson.name === packageName) {
19+
return currentPath;
20+
}
21+
} catch (e) {
22+
// Continue searching up
23+
}
24+
25+
// Also check for .cursor directory as a marker
26+
try {
27+
const cursorPath = resolve(currentPath, '.cursor');
28+
if (statSync(cursorPath).isDirectory()) {
29+
return currentPath;
30+
}
31+
} catch (e) {
32+
// Continue searching up
33+
}
34+
35+
currentPath = dirname(currentPath);
36+
}
37+
38+
throw new Error('Package root not found');
39+
}

cli/utils/interactive-menu.mjs

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import * as fs from 'node:fs/promises';
2-
import * as path from 'node:path';
3-
import * as url from 'node:url';
4-
import { findFolderUp } from './find-folder-up.mjs';
1+
import { readdirSync } from 'node:fs';
2+
import { join, resolve } from 'node:path';
53
/**
64
* Finds all rules in category and prepares them for display in menu
75
* @param {Record<string, Array<{name: string, path: string, fullPath: string}>>} rules
@@ -36,18 +34,18 @@ export const prepareMenu = (rules) => {
3634
export const createMenu = ({ title, items, currentIndex, footerLines = [] }) => {
3735
process.stdout.write('\x1B[2J\x1B[0f');
3836
if (title) {
39-
console.log(title);
40-
console.log('='.repeat(title.length) + '\n');
37+
console.info(title);
38+
console.info('='.repeat(title.length) + '\n');
4139
}
4240
items.forEach((item, idx) => {
4341
const isCurrent = idx === currentIndex;
4442
const indicator = isCurrent ? '▶ ' : ' ';
4543
const highlight = isCurrent ? '\x1B[7m' : '';
4644
const reset = '\x1B[0m';
47-
console.log(`${highlight}${indicator}${item}${reset}`);
45+
console.info(`${highlight}${indicator}${item}${reset}`);
4846
});
4947
if (footerLines.length) {
50-
footerLines.forEach(line => console.log(line));
48+
footerLines.forEach(line => console.info(line));
5149
}
5250
}
5351

@@ -229,32 +227,30 @@ export const selectRules = async (rulesInCategory) => {
229227

230228
/**
231229
* Scan available rules from standards and test directories
230+
* @param {string} rulesBasePath
232231
* @returns {Promise<Record<string, Array<{name: string, path: string, fullPath: string}>>>} Object with categorized rules
233232
*/
234-
export const scanAvailableRules = async () => {
235-
// whereever this function is called, it should find the rules path relative to the current file
236-
const cursorFolder = await findFolderUp('.cursor', process.cwd());
237-
const rulesPath = url.fileURLToPath(url.resolve(import.meta.url, `${cursorFolder}/rules`));
233+
export const scanAvailableRules = async (rulesBasePath) => {
238234

239235
const categories = ['standards', 'test', 'utils'];
240236
/** @type {Record<string, Array<{name: string, path: string, fullPath: string}>>} */
241237
let rules = {}
242238

243239
for (const category of categories) {
244-
const categoryPath = path.join(rulesPath, category);
240+
const categoryPath = resolve(rulesBasePath, category);
245241
try {
246-
const files = await fs.readdir(categoryPath, { withFileTypes: true });
242+
const files = await readdirSync(categoryPath, { withFileTypes: true });
247243
rules[category] = files
248244
.filter(file => file.isFile() && file.name.endsWith('.mdc'))
249245
.map(file => ({
250246
name: file.name.replace('.mdc', ''),
251-
path: path.join(category, file.name),
252-
fullPath: path.join(categoryPath, file.name)
247+
path: join(category, file.name),
248+
fullPath: resolve(categoryPath, file.name)
253249
}));
254250
} catch (err) {
255251
rules[category] = [];
256252
}
257253
}
258254

259255
return rules;
260-
};
256+
};

cli/utils/validate-dirname.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const hasInvalidSegmentChars = (segment) => {
4343
* @returns {Promise<string>} - the validated and resolved absolute path
4444
*/
4545
export const validateDirname = async (rawPath, projectRoot = process.cwd()) => {
46+
console.log('DEBUG: validateDirname rawPath:', rawPath);
47+
console.log('DEBUG: validateDirname projectRoot:', projectRoot);
4648
const attemptedPath = rawPath;
4749

4850
// Remove = prefix if it exists

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)