Skip to content
Open
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/global-pnpm-skill-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Discover pnpm 11 isolated global package roots so `list --global` finds globally installed intent-enabled packages.
8 changes: 6 additions & 2 deletions packages/intent/src/discovery/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,13 @@ export function createPackageRegistrar(opts: CreatePackageRegistrarOptions) {
target: NodeModulesScanTarget,
source: IntentPackage['source'] = 'local',
): void {
if (!target.path || !target.exists || target.scanned) return
if (target.scanned || !target.exists) return
const paths = target.paths ?? (target.path ? [target.path] : [])
if (paths.length === 0) return
target.scanned = true
scanNodeModulesDir(target.path, source)
for (const path of paths) {
scanNodeModulesDir(path, source)
}
}

function tryRegister(
Expand Down
38 changes: 22 additions & 16 deletions packages/intent/src/discovery/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
nodeReadFs,
parseFrontmatter,
readScalarField,
splitGlobalNodeModulesEnvPaths,
toPosixPath,
} from '../shared/utils.js'
import {
Expand Down Expand Up @@ -494,8 +495,9 @@ export function scanForIntents(
const workspaceRoot = findWorkspaceRoot(projectRoot)
const packageManager = detectPackageManager(projectRoot, [workspaceRoot])
const nodeModulesDir = join(projectRoot, 'node_modules')
const explicitGlobalNodeModules =
process.env.INTENT_GLOBAL_NODE_MODULES?.trim() || null
const explicitGlobalNodeModules = splitGlobalNodeModulesEnvPaths(
process.env.INTENT_GLOBAL_NODE_MODULES,
)

const packages: Array<IntentPackage> = []
const warnings: Array<string> = []
Expand All @@ -508,15 +510,15 @@ export function scanForIntents(
scanned: false,
},
global: {
path: explicitGlobalNodeModules,
detected: Boolean(explicitGlobalNodeModules),
exists: explicitGlobalNodeModules
? existsSync(explicitGlobalNodeModules)
: false,
path: explicitGlobalNodeModules[0] ?? null,
paths: explicitGlobalNodeModules,
detected: explicitGlobalNodeModules.length > 0,
exists: explicitGlobalNodeModules.some((path) => existsSync(path)),
scanned: false,
source: explicitGlobalNodeModules
? 'INTENT_GLOBAL_NODE_MODULES'
: undefined,
source:
explicitGlobalNodeModules.length > 0
? 'INTENT_GLOBAL_NODE_MODULES'
: undefined,
},
}
// Track registered package names to avoid duplicates across phases
Expand Down Expand Up @@ -561,14 +563,18 @@ export function scanForIntents(
}

function ensureGlobalNodeModules(): void {
if (!nodeModules.global.path && !explicitGlobalNodeModules) {
if (
(nodeModules.global.paths?.length ?? 0) === 0 &&
explicitGlobalNodeModules.length === 0
) {
const detected = detectGlobalNodeModules(packageManager)
nodeModules.global.path = detected.path
nodeModules.global.path = detected.paths[0] ?? null
nodeModules.global.paths = detected.paths
nodeModules.global.source = detected.source
nodeModules.global.detected = Boolean(detected.path)
nodeModules.global.exists = detected.path
? existsSync(detected.path)
: false
nodeModules.global.detected = detected.paths.length > 0
nodeModules.global.exists = detected.paths.some((path) =>
existsSync(path),
)
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/intent/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface ScanStats {

export interface NodeModulesScanTarget {
path: string | null
/**
* All node_modules roots to scan. The global scope can have several: pnpm 11
* installs each global package into its own isolated directory. `path` is
* kept as the first root for display/backwards compatibility.
*/
paths?: Array<string>
detected: boolean
exists: boolean
scanned: boolean
Expand Down
98 changes: 90 additions & 8 deletions packages/intent/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
realpathSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve, sep } from 'node:path'
import { delimiter, dirname, join, resolve, sep } from 'node:path'
import { parse as parseYaml } from 'yaml'
import type { Dirent } from 'node:fs'

Expand Down Expand Up @@ -153,7 +153,7 @@ export function getDeps(
for (const field of fields) {
const d = pkgJson[field]
if (d && typeof d === 'object') {
for (const name of Object.keys(d as Record<string, string>)) {
for (const name of Object.keys(d)) {
deps.add(name)
}
}
Expand Down Expand Up @@ -258,14 +258,86 @@ export function listNestedNodeModulesPackageDirs(

const GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS = 5_000

/**
* Split the INTENT_GLOBAL_NODE_MODULES override into paths. Accepts a
* `path.delimiter`-separated list so several global roots can be supplied
* (pnpm 11 installs each global package into its own isolated directory, so
* there is no single global node_modules to point at).
*/
export function splitGlobalNodeModulesEnvPaths(
value: string | undefined,
): Array<string> {
if (!value) return []
return value
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}

/**
* Derive the set of global node_modules roots from `pnpm ls -g --json --depth 0`
* output. pnpm 11 installs each global package into its own isolated directory
* (`<global>/<dir>/node_modules/<pkg>`), so there can be several roots. On
* pnpm 10 and earlier every dependency resolves under the one global
* node_modules, so the set collapses to a single root.
*/
export function parsePnpmGlobalLsRoots(output: string): Array<string> {
let importers: unknown
try {
importers = JSON.parse(output)
} catch {
return []
}
if (!Array.isArray(importers)) return []

const marker = `${sep}node_modules`
const roots = new Set<string>()
for (const importer of importers) {
if (!importer || typeof importer !== 'object') continue
const dependencies = (importer as Record<string, unknown>).dependencies
if (!dependencies || typeof dependencies !== 'object') continue
for (const info of Object.values(dependencies)) {
if (!info || typeof info !== 'object') continue
const depPath = (info as Record<string, unknown>).path
if (typeof depPath !== 'string') continue
const index = depPath.lastIndexOf(marker)
if (index === -1) continue
const end = index + marker.length
// Guard against directory names that merely start with "node_modules"
if (end !== depPath.length && depPath[end] !== sep) continue
roots.add(depPath.slice(0, end))
}
}
return [...roots]
}

function detectPnpmGlobalNodeModulesRoots(): Array<string> {
try {
const output = execFileSync(
'pnpm',
['ls', '-g', '--json', '--depth', '0'],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS,
},
)
return parsePnpmGlobalLsRoots(output)
} catch {
return []
}
}

export function detectGlobalNodeModules(packageManager: string): {
path: string | null
paths: Array<string>
source?: string
} {
const envPath = process.env.INTENT_GLOBAL_NODE_MODULES?.trim()
if (envPath) {
const envPaths = splitGlobalNodeModulesEnvPaths(
process.env.INTENT_GLOBAL_NODE_MODULES,
)
if (envPaths.length > 0) {
return {
path: envPath,
paths: envPaths,
source: 'INTENT_GLOBAL_NODE_MODULES',
}
}
Expand All @@ -277,6 +349,16 @@ export function detectGlobalNodeModules(packageManager: string): {
}> = []

if (packageManager === 'pnpm') {
// pnpm 11 installs each global package into its own isolated directory,
// so ask pnpm for the resolved package paths instead of guessing the
// layout. `pnpm root -g` remains below as a fallback.
const roots = detectPnpmGlobalNodeModulesRoots()
if (roots.length > 0) {
return {
paths: roots,
source: 'pnpm ls -g --json --depth 0',
}
}
commands.push({ command: 'pnpm', args: ['root', '-g'] })
}
if (packageManager === 'yarn') {
Expand All @@ -298,15 +380,15 @@ export function detectGlobalNodeModules(packageManager: string): {
if (!output) continue

return {
path: candidate.transform ? candidate.transform(output) : output,
paths: [candidate.transform ? candidate.transform(output) : output],
source: `${candidate.command} ${candidate.args.join(' ')}`,
}
} catch {
continue
}
}

return { path: null }
return { paths: [] }
}

/**
Expand Down
149 changes: 149 additions & 0 deletions packages/intent/tests/global-node-modules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { delimiter, join, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
detectGlobalNodeModules,
parsePnpmGlobalLsRoots,
splitGlobalNodeModulesEnvPaths,
} from '../src/shared/utils.js'

// ── Helpers ──

const globalBase = join(sep, 'Users', 'me', 'Library', 'pnpm', 'global')

function pnpmLsJson(
dependencies: Record<string, { version: string; path: string }>,
): string {
return JSON.stringify([{ path: join(globalBase, 'v11'), dependencies }])
}

// ── Setup / Teardown ──

let previousGlobalNodeModules: string | undefined

beforeEach(() => {
previousGlobalNodeModules = process.env.INTENT_GLOBAL_NODE_MODULES
delete process.env.INTENT_GLOBAL_NODE_MODULES
})

afterEach(() => {
if (previousGlobalNodeModules === undefined) {
delete process.env.INTENT_GLOBAL_NODE_MODULES
} else {
process.env.INTENT_GLOBAL_NODE_MODULES = previousGlobalNodeModules
}
})

// ── parsePnpmGlobalLsRoots ──

describe('parsePnpmGlobalLsRoots', () => {
it('derives one root per isolated global package dir (pnpm 11 layout)', () => {
const rootA = join(globalBase, 'v11', 'd74d-aaa', 'node_modules')
const rootB = join(globalBase, 'v11', 'd74d-bbb', 'node_modules')
const output = pnpmLsJson({
'@tanstack/cli': {
version: '0.70.0',
path: join(rootA, '@tanstack', 'cli'),
},
'playwright-cli': {
version: '1.0.0',
path: join(rootB, 'playwright-cli'),
},
})

expect(parsePnpmGlobalLsRoots(output)).toEqual([rootA, rootB])
})

it('collapses to a single root when all packages share one node_modules (pnpm 10 layout)', () => {
const root = join(globalBase, '5', 'node_modules')
const output = pnpmLsJson({
'@tanstack/cli': {
version: '0.70.0',
path: join(root, '@tanstack', 'cli'),
},
'playwright-cli': {
version: '1.0.0',
path: join(root, 'playwright-cli'),
},
})

expect(parsePnpmGlobalLsRoots(output)).toEqual([root])
})

it('ignores dependencies without a usable path', () => {
const root = join(globalBase, 'v11', 'd74d-aaa', 'node_modules')
const output = JSON.stringify([
{
path: join(globalBase, 'v11'),
dependencies: {
'@tanstack/cli': {
version: '0.70.0',
path: join(root, '@tanstack', 'cli'),
},
'no-path': { version: '1.0.0' },
'outside-node-modules': {
version: '1.0.0',
path: join(globalBase, 'v11', 'plain-dir'),
},
},
},
{ path: join(globalBase, 'v11') },
])

expect(parsePnpmGlobalLsRoots(output)).toEqual([root])
})

it('does not match directories that merely start with node_modules', () => {
const output = pnpmLsJson({
'some-cli': {
version: '1.0.0',
path: join(globalBase, 'v11', 'node_modules_backup', 'some-cli'),
},
})

expect(parsePnpmGlobalLsRoots(output)).toEqual([])
})

it('returns no roots for malformed or non-array output', () => {
expect(parsePnpmGlobalLsRoots('not json')).toEqual([])
expect(parsePnpmGlobalLsRoots('{"path": "/tmp"}')).toEqual([])
expect(parsePnpmGlobalLsRoots('[null, 42]')).toEqual([])
})
})

// ── splitGlobalNodeModulesEnvPaths ──

describe('splitGlobalNodeModulesEnvPaths', () => {
it('returns no paths when the override is unset or blank', () => {
expect(splitGlobalNodeModulesEnvPaths(undefined)).toEqual([])
expect(splitGlobalNodeModulesEnvPaths('')).toEqual([])
expect(splitGlobalNodeModulesEnvPaths(' ')).toEqual([])
})

it('returns a single trimmed path', () => {
const root = join(globalBase, '5', 'node_modules')
expect(splitGlobalNodeModulesEnvPaths(` ${root} `)).toEqual([root])
})

it('splits a path.delimiter-separated list and drops empty entries', () => {
const rootA = join(globalBase, 'v11', 'd74d-aaa', 'node_modules')
const rootB = join(globalBase, 'v11', 'd74d-bbb', 'node_modules')
const value = `${rootA}${delimiter}${delimiter} ${rootB} ${delimiter}`

expect(splitGlobalNodeModulesEnvPaths(value)).toEqual([rootA, rootB])
})
})

// ── detectGlobalNodeModules ──

describe('detectGlobalNodeModules', () => {
it('prefers the INTENT_GLOBAL_NODE_MODULES override, including path lists', () => {
const rootA = join(globalBase, 'v11', 'd74d-aaa', 'node_modules')
const rootB = join(globalBase, 'v11', 'd74d-bbb', 'node_modules')
process.env.INTENT_GLOBAL_NODE_MODULES = `${rootA}${delimiter}${rootB}`

expect(detectGlobalNodeModules('pnpm')).toEqual({
paths: [rootA, rootB],
source: 'INTENT_GLOBAL_NODE_MODULES',
})
})
})
Loading