diff --git a/.changeset/violet-lies-hide.md b/.changeset/violet-lies-hide.md new file mode 100644 index 0000000..b0f4247 --- /dev/null +++ b/.changeset/violet-lies-hide.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix `intent list` in projects with stale Yarn PnP files alongside project `node_modules`, including Bun isolated installs. Intent now prefers project `node_modules` when it exists and only loads Yarn's PnP API for PnP projects without `node_modules`. diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index f074886..551ce76 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -96,10 +96,10 @@ When both local and global packages are scanned, local packages take precedence. `packages` are ordered using `intent.requires` when possible. When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. +When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without `node_modules`, `intent list` uses Yarn's PnP API. ## Common errors - Scanner failures are printed as errors - Unsupported environments: - - Yarn PnP without `node_modules` - Deno projects without `node_modules` diff --git a/docs/overview.md b/docs/overview.md index 8ce970e..3b01284 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -30,7 +30,7 @@ Intent provides tooling for two workflows: npx @tanstack/intent@latest list ``` -Scans the current project's `node_modules` and workspace dependencies for intent-enabled packages. +Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. When both local and global packages are scanned, local packages take precedence. diff --git a/packages/intent/README.md b/packages/intent/README.md index f3704fe..2375425 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -104,7 +104,7 @@ npx @tanstack/intent@latest setup | Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest ` | | Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest ` | | Deno | Best-effort | Requires `npm:` interop and `node_modules` support | -| Yarn PnP | Unsupported | `@tanstack/intent` scans `node_modules` | +| Yarn PnP | Supported | Uses Yarn's PnP API when `node_modules` is absent | ## Monorepos diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index 23b1ba7..f187be8 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -416,7 +416,6 @@ export function scanForIntents( const projectRoot = root ?? process.cwd() const scanScope = getScanScope(options) const packageManager = detectPackageManager(projectRoot) - const pnpApi = scanScope === 'global' ? null : loadPnpApi(projectRoot) const nodeModulesDir = join(projectRoot, 'node_modules') const explicitGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null @@ -450,6 +449,15 @@ export function scanForIntents( string, Map >() + let pnpApi: PnpApi | null | undefined + + function getPnpApi(): PnpApi | null { + if (scanScope === 'global') return null + if (pnpApi === undefined) { + pnpApi = loadPnpApi(projectRoot) + } + return pnpApi + } function rememberVariant(pkg: IntentPackage): void { let variants = packageVariants.get(pkg.name) @@ -515,10 +523,7 @@ export function scanForIntents( warnings, }) - function scanPnpPackages(): void { - if (!pnpApi) return - - const api = pnpApi + function scanPnpPackages(api: PnpApi): void { const visited = new Set() const workspaceRoot = findWorkspaceRoot(projectRoot) const projectLocator = api.findPackageLocator?.( @@ -556,9 +561,12 @@ export function scanForIntents( } function scanLocalPackages(): void { - if (pnpApi && !nodeModules.local.exists) { - scanPnpPackages() - return + if (!nodeModules.local.exists) { + const api = getPnpApi() + if (api) { + scanPnpPackages(api) + return + } } assertLocalNodeModulesSupported(projectRoot) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index e837a65..84843ec 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -669,6 +669,89 @@ describe('scanForIntents', () => { expect(result.packages[0]!.name).toBe('skills-pkg') }) + it('prefers project node_modules over stale PnP state', () => { + const missingPkgJson = join( + root, + '.yarn', + 'cache', + 'bun-wrapper.zip', + 'node_modules', + 'bun-wrapper', + 'package.json', + ) + + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { 'bun-wrapper': '1.0.0' }, + }) + writeFileSync( + join(root, '.pnp.cjs'), + [ + "const Module = require('node:module')", + `const missingPkgJson = ${JSON.stringify(missingPkgJson)}`, + 'module.exports = {', + ' setup() {', + ' const originalResolve = Module._resolveFilename', + ' Module._resolveFilename = function(request, parent, isMain, options) {', + " if (request === 'bun-wrapper/package.json') return missingPkgJson", + ' return originalResolve.call(this, request, parent, isMain, options)', + ' }', + ' },', + ' getDependencyTreeRoots() { return [] },', + ' getPackageInformation() { return null },', + '}', + '', + ].join('\n'), + ) + + const wrapperDir = createDir( + root, + 'node_modules', + '.bun', + 'bun-wrapper@1.0.0', + 'node_modules', + 'bun-wrapper', + ) + writeJson(join(wrapperDir, 'package.json'), { + name: 'bun-wrapper', + version: '1.0.0', + dependencies: { 'bun-skills-pkg': '1.0.0' }, + }) + + const skillsPkgDir = createDir( + root, + 'node_modules', + '.bun', + 'bun-skills-pkg@1.0.0', + 'node_modules', + 'bun-skills-pkg', + ) + writeJson(join(skillsPkgDir, 'package.json'), { + name: 'bun-skills-pkg', + version: '1.0.0', + intent: { version: 1, repo: 'test/skills', docs: 'https://example.com' }, + }) + writeSkillMd(createDir(skillsPkgDir, 'skills', 'core'), { + name: 'core', + description: 'Core skill', + }) + + createDir(root, 'node_modules') + symlinkSync(wrapperDir, join(root, 'node_modules', 'bun-wrapper')) + createDir(wrapperDir, 'node_modules') + symlinkSync( + skillsPkgDir, + join(wrapperDir, 'node_modules', 'bun-skills-pkg'), + ) + + const result = scanForIntents(root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('bun-skills-pkg') + expect(result.warnings).toEqual([]) + }) + it('discovers skills using package.json workspaces', () => { writeJson(join(root, 'package.json'), { name: 'monorepo',