Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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/fix-monorepo-workspace-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/intent': patch
---

Fix monorepo workspace detection so `setup-github-actions`, `validate`, and `stale` behave correctly from repo roots and package directories. Generated workflows now derive skill and watch globs from actual workspace config, including `pnpm-workspace.yaml`, `package.json` workspaces, and Deno workspace files, which avoids broken paths, wrong labels, and false packaging warnings in non-`packages/*` layouts.
32 changes: 17 additions & 15 deletions packages/intent/meta/templates/workflows/validate-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,25 @@ jobs:

- name: Find and validate skills
run: |
# Find all directories containing SKILL.md files
SKILLS_DIR=""
shopt -s globstar 2>/dev/null || true
FOUND=false

# Root-level skills directory
if [ -d "skills" ]; then
SKILLS_DIR="skills"
elif [ -d "packages" ]; then
# Monorepo — find skills/ under packages
for dir in packages/*/skills; do
if [ -d "$dir" ]; then
echo "Validating $dir..."
intent validate "$dir"
fi
done
exit 0
echo "Validating skills..."
intent validate skills
FOUND=true
fi

if [ -n "$SKILLS_DIR" ]; then
intent validate "$SKILLS_DIR"
else
# Workspace package skills derived from workspace config
for dir in {{WORKSPACE_SKILL_GLOBS}}; do
if [ -d "$dir" ]; then
echo "Validating $dir..."
intent validate "$dir"
FOUND=true
fi
done

if [ "$FOUND" = "false" ]; then
echo "No skills/ directory found — skipping validation."
fi
36 changes: 10 additions & 26 deletions packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function scanIntentsOrFail(): Promise<ScanResult> {
const { scanForIntents } = await import('./scanner.js')

try {
return await scanForIntents()
return scanForIntents()
} catch (err) {
fail((err as Error).message)
}
Expand Down Expand Up @@ -225,7 +225,10 @@ async function cmdMeta(args: Array<string>): Promise<void> {
console.log(`Path: node_modules/@tanstack/intent/meta/<name>/SKILL.md`)
}

function collectPackagingWarnings(root: string): Array<string> {
function collectPackagingWarnings(
root: string,
isMonorepo = false,
): Array<string> {
const pkgJsonPath = join(root, 'package.json')
if (!existsSync(pkgJsonPath)) return []

Expand Down Expand Up @@ -260,28 +263,7 @@ function collectPackagingWarnings(root: string): Array<string> {
// Only warn about !skills/_artifacts for non-monorepo packages.
// In monorepos, artifacts live at the repo root, so the negation
// pattern is intentionally omitted by edit-package-json.
const isMonorepoPkg = (() => {
let dir = join(root, '..')
for (let i = 0; i < 5; i++) {
const parentPkg = join(dir, 'package.json')
if (existsSync(parentPkg)) {
try {
const parent = JSON.parse(readFileSync(parentPkg, 'utf8'))
return (
Array.isArray(parent.workspaces) || parent.workspaces?.packages
)
} catch {
return false
}
}
const next = dirname(dir)
if (next === dir) break
dir = next
}
return false
})()

if (!isMonorepoPkg && !files.includes('!skills/_artifacts')) {
if (!isMonorepo && !files.includes('!skills/_artifacts')) {
warnings.push(
'"!skills/_artifacts" is not in the "files" array — artifacts will be published unnecessarily',
)
Expand All @@ -294,7 +276,7 @@ function collectPackagingWarnings(root: string): Array<string> {
function resolvePackageRoot(startDir: string): string {
let dir = startDir

while (true) {
for (;;) {
if (existsSync(join(dir, 'package.json'))) {
return dir
}
Expand Down Expand Up @@ -496,7 +478,9 @@ async function cmdValidate(args: Array<string>): Promise<void> {
}
}

const warnings = collectPackagingWarnings(packageRoot)
const { findWorkspaceRoot } = await import('./setup.js')
const isMonorepo = findWorkspaceRoot(join(packageRoot, '..')) !== null
const warnings = collectPackagingWarnings(packageRoot, isMonorepo)

if (errors.length > 0) {
fail(buildValidationFailure(errors, warnings))
Expand Down
6 changes: 3 additions & 3 deletions packages/intent/src/intent-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import type { LibraryScanResult } from './library-scanner.js'
// Commands
// ---------------------------------------------------------------------------

async function cmdList(): Promise<void> {
function cmdList(): void {
let result: LibraryScanResult
try {
result = await scanLibrary(process.argv[1]!)
result = scanLibrary(process.argv[1]!)
} catch (err) {
console.error((err as Error).message)
process.exit(1)
Expand Down Expand Up @@ -90,7 +90,7 @@ const command = process.argv[2]
switch (command) {
case 'list':
case undefined:
await cmdList()
cmdList()
break
case 'install':
cmdInstall()
Expand Down
27 changes: 12 additions & 15 deletions packages/intent/src/library-scanner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { existsSync, readdirSync } from 'node:fs'
import { dirname, join, relative, sep } from 'node:path'
import { getDeps, parseFrontmatter, resolveDepDir } from './utils.js'
import {
getDeps,
parseFrontmatter,
readPkgJsonFile,
resolveDepDir,
} from './utils.js'
import type { SkillEntry } from './types.js'
import type { Dirent } from 'node:fs'

Expand All @@ -24,14 +29,6 @@ export interface LibraryScanResult {
// Helpers
// ---------------------------------------------------------------------------

function readPkgJson(dir: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
} catch {
return null
}
}

function findHomeDir(scriptPath: string): string | null {
let dir = dirname(scriptPath)
for (;;) {
Expand Down Expand Up @@ -101,10 +98,10 @@ function discoverSkills(skillsDir: string): Array<SkillEntry> {
// Main scanner
// ---------------------------------------------------------------------------

export async function scanLibrary(
export function scanLibrary(
scriptPath: string,
_projectRoot?: string,
): Promise<LibraryScanResult> {
): LibraryScanResult {
const packages: Array<LibraryPackage> = []
const warnings: Array<string> = []
const visited = new Set<string>()
Expand All @@ -117,7 +114,7 @@ export async function scanLibrary(
}
}

const homePkg = readPkgJson(homeDir)
const homePkg = readPkgJsonFile(homeDir)
if (!homePkg) {
return { packages, warnings: ['Could not read home package.json'] }
}
Expand All @@ -128,7 +125,7 @@ export async function scanLibrary(
if (visited.has(name)) return
visited.add(name)

const pkg = readPkgJson(dir)
const pkg = readPkgJsonFile(dir)
if (!pkg) {
warnings.push(`Could not read package.json for ${name}`)
return
Expand All @@ -145,7 +142,7 @@ export async function scanLibrary(
for (const depName of getDeps(pkg)) {
const depDir = resolveDepDir(depName, dir)
if (!depDir) continue
const depPkg = readPkgJson(depDir)
const depPkg = readPkgJsonFile(depDir)
if (depPkg && isIntentPackage(depPkg)) {
processPackage(depName, depDir)
}
Expand Down
15 changes: 6 additions & 9 deletions packages/intent/src/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import {
detectGlobalNodeModules,
getDeps,
listNodeModulesPackageDirs,
normalizeRepoUrl,
parseFrontmatter,
resolveDepDir,
} from './utils.js'
Expand All @@ -21,6 +22,7 @@ import type {
SkillEntry,
VersionConflict,
} from './types.js'
import type { Dirent } from 'node:fs'

// ---------------------------------------------------------------------------
// Package manager detection
Expand Down Expand Up @@ -97,18 +99,13 @@ function deriveIntentConfig(
// Derive repo from repository field
let repo: string | null = null
if (typeof pkgJson.repository === 'string') {
repo = pkgJson.repository
repo = normalizeRepoUrl(pkgJson.repository)
} else if (
pkgJson.repository &&
typeof pkgJson.repository === 'object' &&
typeof (pkgJson.repository as Record<string, unknown>).url === 'string'
) {
repo = (pkgJson.repository as Record<string, unknown>).url as string
// Normalize git+https://github.com/foo/bar.git → foo/bar
repo = repo
.replace(/^git\+/, '')
.replace(/\.git$/, '')
.replace(/^https?:\/\/github\.com\//, '')
repo = normalizeRepoUrl((pkgJson.repository as { url: string }).url)
}

// Derive docs from homepage field
Expand Down Expand Up @@ -318,7 +315,7 @@ function toVersionConflict(
// Main scanner
// ---------------------------------------------------------------------------

export async function scanForIntents(root?: string): Promise<ScanResult> {
export function scanForIntents(root?: string): ScanResult {
const projectRoot = root ?? process.cwd()
const packageManager = detectPackageManager(projectRoot)
const nodeModulesDir = join(projectRoot, 'node_modules')
Expand Down
Loading