diff --git a/.changeset/global-pnpm-skill-discovery.md b/.changeset/global-pnpm-skill-discovery.md new file mode 100644 index 00000000..14a2054a --- /dev/null +++ b/.changeset/global-pnpm-skill-discovery.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Discover pnpm 11 isolated global package roots so `list --global` finds globally installed intent-enabled packages. diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index 90516a07..a4e27140 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -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( diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113ce..cf659533 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -11,6 +11,7 @@ import { nodeReadFs, parseFrontmatter, readScalarField, + splitGlobalNodeModulesEnvPaths, toPosixPath, } from '../shared/utils.js' import { @@ -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 = [] const warnings: Array = [] @@ -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 @@ -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), + ) } } diff --git a/packages/intent/src/shared/types.ts b/packages/intent/src/shared/types.ts index 994ed539..a132535d 100644 --- a/packages/intent/src/shared/types.ts +++ b/packages/intent/src/shared/types.ts @@ -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 detected: boolean exists: boolean scanned: boolean diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index f3d6930e..8cf44116 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -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' @@ -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)) { + for (const name of Object.keys(d)) { deps.add(name) } } @@ -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 { + 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 + * (`//node_modules/`), 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 { + let importers: unknown + try { + importers = JSON.parse(output) + } catch { + return [] + } + if (!Array.isArray(importers)) return [] + + const marker = `${sep}node_modules` + const roots = new Set() + for (const importer of importers) { + if (!importer || typeof importer !== 'object') continue + const dependencies = (importer as Record).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).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 { + 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 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', } } @@ -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') { @@ -298,7 +380,7 @@ 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 { @@ -306,7 +388,7 @@ export function detectGlobalNodeModules(packageManager: string): { } } - return { path: null } + return { paths: [] } } /** diff --git a/packages/intent/tests/global-node-modules.test.ts b/packages/intent/tests/global-node-modules.test.ts new file mode 100644 index 00000000..a176e251 --- /dev/null +++ b/packages/intent/tests/global-node-modules.test.ts @@ -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 { + 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', + }) + }) +}) diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c3..c19bc286 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -8,7 +8,7 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' -import { join, sep } from 'node:path' +import { delimiter, join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { @@ -620,6 +620,99 @@ describe('scanForIntents', () => { expect(result.packages).toEqual([]) }) + it('discovers global packages across multiple pnpm-style isolated global roots', () => { + // pnpm 11 installs each global package into its own isolated directory: + // //node_modules/, so there are several global roots. + const rootA = createDir(globalRoot, 'd74d-aaa', 'node_modules') + const rootB = createDir(globalRoot, 'd74d-bbb', 'node_modules') + process.env.INTENT_GLOBAL_NODE_MODULES = `${rootA}${delimiter}${rootB}` + + const pkgDirA = createDir(rootA, '@tanstack', 'cli') + writeJson(join(pkgDirA, 'package.json'), { + name: '@tanstack/cli', + version: '0.70.0', + intent: { version: 1, repo: 'TanStack/cli', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDirA, 'skills', 'scaffolding'), { + name: 'scaffolding', + description: 'Global scaffolding skill', + }) + + const pkgDirB = createDir(rootB, 'other-cli') + writeJson(join(pkgDirB, 'package.json'), { + name: 'other-cli', + version: '1.0.0', + intent: { version: 1, repo: 'example/other-cli', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDirB, 'skills', 'testing'), { + name: 'testing', + description: 'Global testing skill', + }) + + const result = scanForIntents(root, { scope: 'global' }) + + expect(result.nodeModules.global.detected).toBe(true) + expect(result.nodeModules.global.exists).toBe(true) + expect(result.nodeModules.global.scanned).toBe(true) + expect(result.nodeModules.global.paths).toEqual([rootA, rootB]) + expect(result.nodeModules.global.path).toBe(rootA) + expect(result.packages.map((pkg) => pkg.name).sort()).toEqual([ + '@tanstack/cli', + 'other-cli', + ]) + expect(result.packages.every((pkg) => pkg.source === 'global')).toBe(true) + expect(result.stats.packageJsonReadCount).toBeGreaterThan(0) + }) + + it('discovers global packages in an npm-style single global root', () => { + // npm (and pnpm <= 10) keep one global node_modules directory. + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + + const pkgDir = createDir(globalRoot, '@tanstack', 'cli') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/cli', + version: '0.70.0', + intent: { version: 1, repo: 'TanStack/cli', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDir, 'skills', 'scaffolding'), { + name: 'scaffolding', + description: 'Global scaffolding skill', + }) + + const result = scanForIntents(root, { scope: 'global' }) + + expect(result.nodeModules.global.paths).toEqual([globalRoot]) + expect(result.nodeModules.global.scanned).toBe(true) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/cli') + expect(result.packages[0]!.source).toBe('global') + }) + + it('dedupes global roots that alias the same directory', () => { + // A shared pnpm store can expose the same node_modules under two paths. + const realRoot = createDir(globalRoot, 'd74d-aaa', 'node_modules') + const aliasRoot = join(globalRoot, 'alias-node-modules') + symlinkSync(realRoot, aliasRoot, 'dir') + process.env.INTENT_GLOBAL_NODE_MODULES = `${realRoot}${delimiter}${aliasRoot}` + + const pkgDir = createDir(realRoot, '@tanstack', 'cli') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/cli', + version: '0.70.0', + intent: { version: 1, repo: 'TanStack/cli', docs: 'docs/' }, + }) + writeSkillMd(createDir(pkgDir, 'skills', 'scaffolding'), { + name: 'scaffolding', + description: 'Global scaffolding skill', + }) + + const result = scanForIntents(root, { scope: 'global' }) + + expect(result.nodeModules.global.paths).toEqual([realRoot, aliasRoot]) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/cli') + }) + it('chooses the highest version when duplicate package names exist at the same depth', () => { writeJson(join(root, 'package.json'), { name: 'app', diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index fa266570..b85954d9 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -73,11 +73,11 @@ function mockFetchVersion(version: string): void { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ version }), - } as Response) + }) } function mockFetchNotOk(): void { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) } beforeEach(() => {