Skip to content
Closed
5 changes: 5 additions & 0 deletions .changeset/busy-peas-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Fix workspace package discovery for nested glob patterns, including support for `*` and `**`. Workspace patterns and resolved package roots are now normalized, deduped, and sorted, and the shared resolver has been extracted for reuse by internal workspace scanning.
2 changes: 1 addition & 1 deletion packages/intent/src/cli-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function resolveStaleTargets(
}

const { findPackagesWithSkills, findWorkspaceRoot } =
await import('./setup.js')
await import('./workspace-patterns.js')
const workspaceRoot = findWorkspaceRoot(resolvedRoot)
if (workspaceRoot) {
const packageDirs = findPackagesWithSkills(workspaceRoot)
Expand Down
2 changes: 1 addition & 1 deletion packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
findWorkspaceRoot,
readWorkspacePatterns,
resolveWorkspacePackages,
} from './setup.js'
} from './workspace-patterns.js'
import type {
InstalledVariant,
IntentConfig,
Expand Down
160 changes: 15 additions & 145 deletions packages/intent/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ import {
writeFileSync,
} from 'node:fs'
import { basename, join, relative } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { findSkillFiles } from './utils.js'
import {
findPackagesWithSkills,
findWorkspaceRoot,
readWorkspacePatterns,
} from './workspace-patterns.js'

export {
findPackagesWithSkills,
findWorkspaceRoot,
readWorkspacePatterns,
resolveWorkspacePackages,
} from './workspace-patterns.js'

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -320,7 +330,9 @@ export function runEditPackageJson(root: string): EditPackageJsonResult {
if (Array.isArray(parent.workspaces) || parent.workspaces?.packages) {
return true
}
} catch {}
} catch (err) {
console.error(`Warning: could not read ${parentPkg}: ${err instanceof Error ? err.message : err}`)
}
return false
}
const next = join(dir, '..')
Expand Down Expand Up @@ -351,148 +363,6 @@ export function runEditPackageJson(root: string): EditPackageJsonResult {
return result
}

// ---------------------------------------------------------------------------
// Monorepo workspace resolution
// ---------------------------------------------------------------------------

export function readWorkspacePatterns(root: string): Array<string> | null {
// pnpm-workspace.yaml
const pnpmWs = join(root, 'pnpm-workspace.yaml')
if (existsSync(pnpmWs)) {
try {
const config = parseYaml(readFileSync(pnpmWs, 'utf8')) as Record<
string,
unknown
>
if (Array.isArray(config.packages)) {
return config.packages as Array<string>
}
} catch (err: unknown) {
console.error(
`Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`,
)
}
}

// package.json workspaces
const pkgPath = join(root, 'package.json')
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (Array.isArray(pkg.workspaces)) {
return pkg.workspaces
}
if (Array.isArray(pkg.workspaces?.packages)) {
return pkg.workspaces.packages
}
} catch (err: unknown) {
console.error(
`Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`,
)
}
}

return null
}

/**
* Resolve workspace glob patterns to actual package directories.
* Handles simple patterns like "packages/*" and "packages/**".
* Each resolved directory must contain a package.json.
*/
export function resolveWorkspacePackages(
root: string,
patterns: Array<string>,
): Array<string> {
const dirs: Array<string> = []

for (const pattern of patterns) {
// Strip trailing /* or /**/* for directory resolution
const base = pattern.replace(/\/\*\*?(\/\*)?$/, '')
const baseDir = join(root, base)
if (!existsSync(baseDir)) continue

if (pattern.includes('**')) {
// Recursive: walk all subdirectories
collectPackageDirs(baseDir, dirs)
} else if (pattern.endsWith('/*')) {
// Single level: direct children
let entries: Array<import('node:fs').Dirent>
try {
entries = readdirSync(baseDir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const dir = join(baseDir, entry.name)
if (existsSync(join(dir, 'package.json'))) {
dirs.push(dir)
}
}
} else {
// Exact path
const dir = join(root, pattern)
if (existsSync(join(dir, 'package.json'))) {
dirs.push(dir)
}
}
}

return dirs
}

function collectPackageDirs(dir: string, result: Array<string>): void {
if (existsSync(join(dir, 'package.json'))) {
result.push(dir)
}
let entries: Array<import('node:fs').Dirent>
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch (err: unknown) {
console.error(
`Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`,
)
return
}
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.name === 'node_modules' ||
entry.name.startsWith('.')
)
continue
collectPackageDirs(join(dir, entry.name), result)
}
}

export function findWorkspaceRoot(start: string): string | null {
let dir = start

while (true) {
if (readWorkspacePatterns(dir)) {
return dir
}

const next = join(dir, '..')
if (next === dir) return null
dir = next
}
}

/**
* Find workspace packages that contain at least one SKILL.md file.
*/
export function findPackagesWithSkills(root: string): Array<string> {
const patterns = readWorkspacePatterns(root)
if (!patterns) return []

return resolveWorkspacePackages(root, patterns).filter((dir) => {
const skillsDir = join(dir, 'skills')
return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0
})
}

// ---------------------------------------------------------------------------
// Monorepo-aware command runner
// ---------------------------------------------------------------------------
Expand Down
187 changes: 187 additions & 0 deletions packages/intent/src/workspace-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import type { Dirent } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { findSkillFiles } from './utils.js'

function normalizeWorkspacePattern(pattern: string): string {
return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
}

function normalizeWorkspacePatterns(patterns: Array<string>): Array<string> {
return [
...new Set(patterns.map(normalizeWorkspacePattern).filter(Boolean)),
].sort((a, b) => a.localeCompare(b))
}

function parseWorkspacePatterns(value: unknown): Array<string> | null {
if (!Array.isArray(value)) {
return null
}

const normalized = normalizeWorkspacePatterns(
value.filter((pattern): pattern is string => typeof pattern === 'string'),
)
return normalized.length > 0 ? normalized : null
}

function hasPackageJson(dir: string): boolean {
return existsSync(join(dir, 'package.json'))
}

/** Reads workspace patterns from pnpm-workspace.yaml (preferred) or package.json workspaces. Returns null if no workspace config is found. */
export function readWorkspacePatterns(root: string): Array<string> | null {
const pnpmWs = join(root, 'pnpm-workspace.yaml')
if (existsSync(pnpmWs)) {
try {
const config = parseYaml(readFileSync(pnpmWs, 'utf8')) as Record<
string,
unknown
>
const patterns = parseWorkspacePatterns(config.packages)
if (patterns) {
return patterns
}
} catch (err: unknown) {
console.error(
`Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`,
)
}
}

const pkgPath = join(root, 'package.json')
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const workspaces = parseWorkspacePatterns(pkg.workspaces)
if (workspaces) {
return workspaces
}

const workspacePackages = parseWorkspacePatterns(pkg.workspaces?.packages)
if (workspacePackages) {
return workspacePackages
}
} catch (err: unknown) {
console.error(
`Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`,
)
}
}

return null
}

/** Resolves workspace glob patterns to package directories. Supports `*`, `**`, and `!` exclusion patterns. */
export function resolveWorkspacePackages(
root: string,
patterns: Array<string>,
): Array<string> {
const includedDirs = new Set<string>()
const excludedDirs = new Set<string>()

for (const pattern of normalizeWorkspacePatterns(patterns)) {
if (pattern.startsWith('!')) {
resolveWorkspacePatternSegments(
root,
pattern.slice(1).split('/'),
excludedDirs,
)
continue
}

resolveWorkspacePatternSegments(root, pattern.split('/'), includedDirs)
}

return [...includedDirs]
.filter((dir) => !excludedDirs.has(dir))
.sort((a, b) => a.localeCompare(b))
}

function resolveWorkspacePatternSegments(
dir: string,
segments: Array<string>,
result: Set<string>,
): void {
if (segments.length === 0) {
if (hasPackageJson(dir)) {
result.add(dir)
}
return
}

const segment = segments[0]!
const remainingSegments = segments.slice(1)

if (segment === '**') {
resolveWorkspacePatternSegments(dir, remainingSegments, result)
for (const childDir of readChildDirectories(dir)) {
resolveWorkspacePatternSegments(childDir, segments, result)
}
return
}

if (segment === '*') {
for (const childDir of readChildDirectories(dir)) {
resolveWorkspacePatternSegments(childDir, remainingSegments, result)
}
return
}

const nextDir = join(dir, segment)
if (!existsSync(nextDir)) {
return
}

resolveWorkspacePatternSegments(nextDir, remainingSegments, result)
}

function readChildDirectories(dir: string): Array<string> {
let entries: Array<Dirent>
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch (err: unknown) {
console.error(
`Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`,
)
return []
}

return entries
.filter(
(entry) =>
entry.isDirectory() &&
entry.name !== 'node_modules' &&
!entry.name.startsWith('.'),
)
.map((entry) => join(dir, entry.name))
}

/** Walks up from `start` to find the nearest directory with a workspace config. */
export function findWorkspaceRoot(start: string): string | null {
if (!isAbsolute(start)) {
throw new Error(`findWorkspaceRoot requires an absolute path, got: ${start}`)
}
let dir = start

while (true) {
if (readWorkspacePatterns(dir)) {
return dir
}

const next = join(dir, '..')
if (next === dir) return null
dir = next
}
}

/** Finds workspace packages that contain at least one SKILL.md file. */
export function findPackagesWithSkills(root: string): Array<string> {
const patterns = readWorkspacePatterns(root)
if (!patterns) return []

return resolveWorkspacePackages(root, patterns).filter((dir) => {
const skillsDir = join(dir, 'skills')
return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0
})
}
Loading