Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/violet-lies-hide.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion docs/cli/intent-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 1 addition & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ npx @tanstack/intent@latest setup
| Node.js + pnpm | Supported | Use `pnpm dlx @tanstack/intent@latest <command>` |
| Node.js + Bun | Supported | Use `bunx @tanstack/intent@latest <command>` |
| 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

Expand Down
24 changes: 16 additions & 8 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -450,6 +449,15 @@ export function scanForIntents(
string,
Map<string, { version: string; packageRoot: string }>
>()
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)
Expand Down Expand Up @@ -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<string>()
const workspaceRoot = findWorkspaceRoot(projectRoot)
const projectLocator = api.findPackageLocator?.(
Expand Down Expand Up @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions packages/intent/tests/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading