diff --git a/.changeset/fix-monorepo-workspace-detection.md b/.changeset/fix-monorepo-workspace-detection.md new file mode 100644 index 0000000..3969967 --- /dev/null +++ b/.changeset/fix-monorepo-workspace-detection.md @@ -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. diff --git a/packages/intent/meta/templates/workflows/validate-skills.yml b/packages/intent/meta/templates/workflows/validate-skills.yml index 8f39716..e4f13c1 100644 --- a/packages/intent/meta/templates/workflows/validate-skills.yml +++ b/packages/intent/meta/templates/workflows/validate-skills.yml @@ -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 diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 0c74230..c9768a6 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -225,7 +225,10 @@ async function cmdMeta(args: Array): Promise { console.log(`Path: node_modules/@tanstack/intent/meta//SKILL.md`) } -function collectPackagingWarnings(root: string): Array { +function collectPackagingWarnings( + root: string, + isMonorepo = false, +): Array { const pkgJsonPath = join(root, 'package.json') if (!existsSync(pkgJsonPath)) return [] @@ -260,28 +263,7 @@ function collectPackagingWarnings(root: string): Array { // 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', ) @@ -294,7 +276,7 @@ function collectPackagingWarnings(root: string): Array { function resolvePackageRoot(startDir: string): string { let dir = startDir - while (true) { + for (;;) { if (existsSync(join(dir, 'package.json'))) { return dir } @@ -496,7 +478,9 @@ async function cmdValidate(args: Array): Promise { } } - 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)) diff --git a/packages/intent/src/library-scanner.ts b/packages/intent/src/library-scanner.ts index 44e3e81..cc21f73 100644 --- a/packages/intent/src/library-scanner.ts +++ b/packages/intent/src/library-scanner.ts @@ -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' @@ -24,14 +29,6 @@ export interface LibraryScanResult { // Helpers // --------------------------------------------------------------------------- -function readPkgJson(dir: string): Record | 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 (;;) { @@ -117,7 +114,7 @@ export function scanLibrary( } } - const homePkg = readPkgJson(homeDir) + const homePkg = readPkgJsonFile(homeDir) if (!homePkg) { return { packages, warnings: ['Could not read home package.json'] } } @@ -128,7 +125,7 @@ export 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 @@ -145,7 +142,7 @@ export 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) } diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index f61e129..c7641b2 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -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' @@ -21,6 +22,7 @@ import type { SkillEntry, VersionConflict, } from './types.js' +import type { Dirent } from 'node:fs' // --------------------------------------------------------------------------- // Package manager detection @@ -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).url === 'string' ) { - repo = (pkgJson.repository as Record).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 diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 8ff32f0..181670d 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -7,7 +7,7 @@ import { } from 'node:fs' import { basename, join, relative } from 'node:path' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from './utils.js' +import { findSkillFiles, normalizeRepoUrl, readPkgJsonFile } from './utils.js' // --------------------------------------------------------------------------- // Types @@ -36,31 +36,18 @@ interface TemplateVars { DOCS_PATH: string SRC_PATH: string WATCH_PATHS: string + WORKSPACE_SKILL_GLOBS: string +} + +interface MonorepoTemplateContext { + packageDirsWithSkills: Array + workspacePatterns: Array } // --------------------------------------------------------------------------- // Variable detection from package.json // --------------------------------------------------------------------------- -function readPackageJson(root: string): Record { - const pkgPath = join(root, 'package.json') - try { - return JSON.parse(readFileSync(pkgPath, 'utf8')) as Record - } catch (err: unknown) { - const isNotFound = - err && - typeof err === 'object' && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - if (!isNotFound) { - console.error( - `Warning: could not read ${pkgPath}: ${err instanceof Error ? err.message : err}`, - ) - } - return {} - } -} - function detectRepo( pkgJson: Record, fallback: string, @@ -71,10 +58,7 @@ function detectRepo( } if (typeof pkgJson.repository === 'string') { - return pkgJson.repository - .replace(/^git\+/, '') - .replace(/\.git$/, '') - .replace(/^https?:\/\/github\.com\//, '') + return normalizeRepoUrl(pkgJson.repository) } if ( @@ -82,22 +66,146 @@ function detectRepo( typeof pkgJson.repository === 'object' && typeof (pkgJson.repository as Record).url === 'string' ) { - return ((pkgJson.repository as Record).url as string) - .replace(/^git\+/, '') - .replace(/\.git$/, '') - .replace(/^https?:\/\/github\.com\//, '') + return normalizeRepoUrl( + (pkgJson.repository as Record).url as string, + ) } return fallback } +function isEnoent(err: unknown): boolean { + return ( + !!err && + typeof err === 'object' && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + function normalizePattern(pattern: string): string { return pattern.endsWith('**') ? pattern : pattern.replace(/\/$/, '') + '/**' } -function buildWatchPaths(root: string, packageDirs: Array): string { +function normalizeWorkspacePattern(pattern: string): string { + return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '') +} + +function normalizeWorkspacePatterns( + patterns: Array | null | undefined, +): Array { + if (!patterns) return [] + + return [ + ...new Set(patterns.map(normalizeWorkspacePattern).filter(Boolean)), + ].sort() +} + +function sanitizeJsonc(content: string): string { + let result = '' + let inString = false + let escaped = false + + for (let i = 0; i < content.length; i++) { + const char = content[i]! + const next = content[i + 1] + + if (inString) { + result += char + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + result += char + continue + } + + // Strip // line comments + if (char === '/' && next === '/') { + while (i < content.length && content[i] !== '\n') i++ + if (i < content.length) result += '\n' + continue + } + + // Strip /* block comments */ + if (char === '/' && next === '*') { + i += 2 + while ( + i < content.length && + !(content[i] === '*' && content[i + 1] === '/') + ) { + i++ + } + i++ + continue + } + + // Strip trailing commas before ] or } + if (char === ',') { + let j = i + 1 + while (j < content.length && /\s/.test(content[j]!)) j++ + if (content[j] === ']' || content[j] === '}') { + continue + } + } + + result += char + } + + return result +} + +function readJsoncFile(path: string): Record | null { + try { + const raw = readFileSync(path, 'utf8') + return JSON.parse(sanitizeJsonc(raw)) as Record + } catch (err: unknown) { + if (!isEnoent(err)) { + console.error( + `Warning: failed to parse ${path}: ${err instanceof Error ? err.message : err}`, + ) + } + return null + } +} + +function buildFallbackWorkspacePaths( + patterns: Array, + suffixes: Array, +): Array { const paths = new Set() + for (const pattern of patterns) { + const normalized = normalizeWorkspacePattern(pattern) + for (const suffix of suffixes) { + paths.add(`${normalized}/${suffix}`) + } + } + + return [...paths].sort() +} + +function buildWorkspaceSkillGlobs(patterns: Array): string { + const globs = buildFallbackWorkspacePaths(patterns, ['skills']) + return globs.length > 0 ? globs.join(' ') : '__intent_no_workspace_skills__' +} + +function buildWatchPaths( + root: string, + packageDirs: Array, + workspacePatterns: Array, +): string { + const paths = new Set() + let hasPackageSpecificPaths = false + if (existsSync(join(root, 'docs'))) { paths.add('docs/**') } @@ -106,13 +214,24 @@ function buildWatchPaths(root: string, packageDirs: Array): string { const relDir = relative(root, packageDir).split('\\').join('/') if (existsSync(join(packageDir, 'src'))) { paths.add(`${relDir}/src/**`) + hasPackageSpecificPaths = true } - const pkgJson = readPackageJson(packageDir) + const pkgJson = readPkgJsonFile(packageDir) || {} const intent = pkgJson.intent as Record | undefined const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' if (!docs.startsWith('http://') && !docs.startsWith('https://')) { paths.add(normalizePattern(join(relDir, docs).split('\\').join('/'))) + hasPackageSpecificPaths = true + } + } + + if (!hasPackageSpecificPaths) { + for (const path of buildFallbackWorkspacePaths(workspacePatterns, [ + 'src/**', + 'docs/**', + ])) { + paths.add(path) } } @@ -127,24 +246,43 @@ function buildWatchPaths(root: string, packageDirs: Array): string { .join('\n') } -function detectVars(root: string, packageDirs?: Array): TemplateVars { - const pkgJson = readPackageJson(root) +function detectVars( + root: string, + context?: MonorepoTemplateContext, +): TemplateVars { + const pkgJson = readPkgJsonFile(root) || {} const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' const docs = typeof (pkgJson.intent as Record | undefined)?.docs === 'string' ? ((pkgJson.intent as Record).docs as string) : 'docs/' - const repo = detectRepo(pkgJson, name.replace(/^@/, '').replace(/\//, '/')) - const isMonorepo = packageDirs !== undefined + const defaultRepo = name.replace(/^@/, '').replace(/\//, '/') + const repo = detectRepo(pkgJson, defaultRepo) + const workspacePatterns = context?.workspacePatterns ?? [] + const isMonorepo = workspacePatterns.length > 0 + const packageDirs = context?.packageDirsWithSkills ?? [] + const isPrivateRoot = pkgJson.private === true + const hasUselessName = + name === 'unknown' || (isPrivateRoot && !name.startsWith('@')) const packageLabel = - isMonorepo && name === 'unknown' ? `${basename(root)} workspace` : name + isMonorepo && hasUselessName + ? repo !== defaultRepo + ? repo + : `${basename(root)} workspace` + : name // Best-guess src path from common monorepo patterns const shortName = name.replace(/^@[^/]+\//, '') - let srcPath = `packages/${shortName}/src/**` - if (existsSync(join(root, 'src'))) { + let srcPath: string + if (isMonorepo) { + srcPath = + buildFallbackWorkspacePaths(workspacePatterns, ['src/**'])[0] ?? + 'packages/*/src/**' + } else if (existsSync(join(root, 'src'))) { srcPath = 'src/**' + } else { + srcPath = `packages/${shortName}/src/**` } return { @@ -155,8 +293,11 @@ function detectVars(root: string, packageDirs?: Array): TemplateVars { DOCS_PATH: docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**', SRC_PATH: srcPath, WATCH_PATHS: isMonorepo - ? buildWatchPaths(root, packageDirs) + ? buildWatchPaths(root, packageDirs, workspacePatterns) : ` - '${docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**'}'\n - '${srcPath}'`, + WORKSPACE_SKILL_GLOBS: buildWorkspaceSkillGlobs( + isMonorepo ? workspacePatterns : [], + ), } } @@ -173,6 +314,7 @@ function applyVars(content: string, vars: TemplateVars): string { .replace(/\{\{DOCS_PATH\}\}/g, vars.DOCS_PATH) .replace(/\{\{SRC_PATH\}\}/g, vars.SRC_PATH) .replace(/\{\{WATCH_PATHS\}\}/g, vars.WATCH_PATHS) + .replace(/\{\{WORKSPACE_SKILL_GLOBS\}\}/g, vars.WORKSPACE_SKILL_GLOBS) } // --------------------------------------------------------------------------- @@ -204,7 +346,7 @@ function copyTemplates( if (vars.WATCH_PATHS.includes('\n')) { content = content.replace( /\s+- '?\{\{DOCS_PATH\}\}'?\n\s+- '?\{\{SRC_PATH\}\}'?/, - vars.WATCH_PATHS, + `\n${vars.WATCH_PATHS}`, ) } const substituted = applyVars(content, vars) @@ -263,26 +405,7 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { // In monorepos, _artifacts lives at repo root, not under packages — // the negation pattern is a no-op and shouldn't be added. - // Detect monorepo by walking up to find a parent package.json with workspaces. - const isMonorepo = (() => { - 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')) - if (Array.isArray(parent.workspaces) || parent.workspaces?.packages) { - return true - } - } catch {} - return false - } - const next = join(dir, '..') - if (next === dir) break - dir = next - } - return false - })() + const isMonorepo = findWorkspaceRoot(join(root, '..')) !== null const requiredFiles = isMonorepo ? ['skills'] : ['skills', '!skills/_artifacts'] @@ -311,41 +434,54 @@ export function runEditPackageJson(root: string): EditPackageJsonResult { export function readWorkspacePatterns(root: string): Array | 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 - } - } catch (err: unknown) { + try { + const raw = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') + const config = parseYaml(raw) as Record + if (Array.isArray(config.packages)) { + return normalizeWorkspacePatterns(config.packages as Array) + } + } catch (err: unknown) { + if (!isEnoent(err)) { console.error( - `Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`, + `Warning: failed to parse pnpm-workspace.yaml: ${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) { + // package.json workspaces (npm, yarn, bun) + try { + const pkg = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), + ) as Record + const ws = pkg.workspaces + if (Array.isArray(ws)) { + return normalizeWorkspacePatterns(ws as Array) + } + if ( + ws && + typeof ws === 'object' && + Array.isArray((ws as Record).packages) + ) { + return normalizeWorkspacePatterns( + (ws as Record).packages as Array, + ) + } + } catch (err: unknown) { + if (!isEnoent(err)) { console.error( - `Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`, + `Warning: failed to parse package.json: ${err instanceof Error ? err.message : err}`, ) } } + // deno.json / deno.jsonc + for (const name of ['deno.json', 'deno.jsonc']) { + const config = readJsoncFile(join(root, name)) + if (config && Array.isArray(config.workspace)) { + return normalizeWorkspacePatterns(config.workspace as Array) + } + } + return null } @@ -361,34 +497,10 @@ export function resolveWorkspacePackages( const dirs: Array = [] 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 - 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) + const segments = pattern.split('/') + for (const resolved of resolveGlob(root, segments)) { + if (existsSync(join(resolved, 'package.json'))) { + dirs.push(resolved) } } } @@ -396,34 +508,46 @@ export function resolveWorkspacePackages( return dirs } -function collectPackageDirs(dir: string, result: Array): void { - if (existsSync(join(dir, 'package.json'))) { - result.push(dir) +function resolveGlob(base: string, segments: Array): Array { + if (segments.length === 0) return [base] + + const [head, ...rest] = segments + + if (head === '*') { + return listChildDirs(base).flatMap((dir) => resolveGlob(dir, rest)) } - let entries: Array - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch (err: unknown) { - console.error( - `Warning: could not read directory ${dir}: ${err instanceof Error ? err.message : err}`, - ) - return + + if (head === '**') { + const results = resolveGlob(base, rest) + for (const dir of listChildDirs(base)) { + results.push(...resolveGlob(dir, segments)) + } + return results } - for (const entry of entries) { - if ( - !entry.isDirectory() || - entry.name === 'node_modules' || - entry.name.startsWith('.') - ) - continue - collectPackageDirs(join(dir, entry.name), result) + + const next = join(base, head!) + return existsSync(next) ? resolveGlob(next, rest) : [] +} + +function listChildDirs(dir: string): Array { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter( + (e) => + e.isDirectory() && + e.name !== 'node_modules' && + !e.name.startsWith('.'), + ) + .map((e) => join(dir, e.name)) + } catch { + return [] } } export function findWorkspaceRoot(start: string): string | null { let dir = start - while (true) { + for (;;) { if (readWorkspacePatterns(dir)) { return dir } @@ -461,8 +585,14 @@ function runForEachPackage( root: string, runOne: (dir: string) => T, ): Array> | T { - const isMonorepo = readWorkspacePatterns(root) !== null - const pkgsWithSkills = isMonorepo ? findPackagesWithSkills(root) : [] + const patterns = readWorkspacePatterns(root) + const isMonorepo = patterns !== null + const pkgsWithSkills = isMonorepo + ? resolveWorkspacePackages(root, patterns).filter((dir) => { + const skillsDir = join(dir, 'skills') + return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 + }) + : [] if (!isMonorepo) { return runOne(root) @@ -495,11 +625,13 @@ export function runSetupGithubActions( metaDir: string, ): SetupGithubActionsResult { const workspaceRoot = findWorkspaceRoot(root) ?? root - const packageDirs = findPackagesWithSkills(workspaceRoot) - const vars = detectVars( - workspaceRoot, - packageDirs.length > 0 ? packageDirs : undefined, - ) + const workspacePatterns = readWorkspacePatterns(workspaceRoot) + const isMonorepo = workspacePatterns !== null + const packageDirs = isMonorepo ? findPackagesWithSkills(workspaceRoot) : [] + const vars = detectVars(workspaceRoot, { + packageDirsWithSkills: packageDirs, + workspacePatterns: workspacePatterns ?? [], + }) const result: SetupGithubActionsResult = { workflows: [], skipped: [] } const srcDir = join(metaDir, 'templates', 'workflows') @@ -518,7 +650,7 @@ export function runSetupGithubActions( console.log(` Package: ${vars.PACKAGE_LABEL}`) console.log(` Repo: ${vars.REPO}`) console.log( - ` Mode: ${packageDirs.length > 0 ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, + ` Mode: ${isMonorepo ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, ) } diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 679c2ec..fcba94e 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -89,7 +89,7 @@ function parseSyncState(value: unknown): SyncState | null { skills[skillName] = {} if (sourcesSha) { - skills[skillName]!.sources_sha = sourcesSha + skills[skillName].sources_sha = sourcesSha } } diff --git a/packages/intent/src/utils.ts b/packages/intent/src/utils.ts index 4e68b5b..c517422 100644 --- a/packages/intent/src/utils.ts +++ b/packages/intent/src/utils.ts @@ -1,8 +1,9 @@ import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs' +import { existsSync, readFileSync, readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' +import type { Dirent } from 'node:fs' /** * Recursively find all SKILL.md files under a directory. @@ -171,7 +172,7 @@ export function resolveDepDir( // Fallback: walk up from parentDir checking node_modules/. // Handles packages with exports maps that don't expose ./package.json. let dir = parentDir - while (true) { + for (;;) { const candidate = join(dir, 'node_modules', depName) if (existsSync(join(candidate, 'package.json'))) return candidate const parent = dirname(dir) @@ -182,6 +183,25 @@ export function resolveDepDir( return null } +export function normalizeRepoUrl(url: string): string { + return url + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') +} + +export function readPkgJsonFile( + dirPath: string, +): Record | null { + try { + return JSON.parse( + readFileSync(join(dirPath, 'package.json'), 'utf8'), + ) as Record + } catch { + return null + } +} + /** * Parse YAML frontmatter from a file. Returns null if no frontmatter or on error. */ diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 756f590..1ef1a08 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -12,7 +12,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { INSTALL_PROMPT } from '../src/install-prompt.js' -import { main, USAGE } from '../src/cli.js' +import { USAGE, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') @@ -288,6 +288,36 @@ describe('cli commands', () => { expect(output).not.toContain('@tanstack/intent is not in devDependencies') }) + it('validates pnpm monorepo package skills without false !skills/_artifacts warning', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-pnpm-')) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { private: true }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ) + writeJson(join(root, 'packages', 'router', 'package.json'), { + name: '@tanstack/router', + devDependencies: { '@tanstack/intent': '^0.0.20' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeSkillMd(join(root, 'packages', 'router', 'skills', 'routing'), { + name: 'routing', + description: 'Routing skill', + }) + + process.chdir(root) + + const exitCode = await main(['validate', 'packages/router/skills']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('!skills/_artifacts') + }) + it('fails cleanly when validate is run without a skills directory', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-missing-skills-')) tempDirs.push(root) @@ -350,7 +380,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index c74940b..571a848 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -8,8 +8,11 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + findPackagesWithSkills, + readWorkspacePatterns, + resolveWorkspacePackages, runEditPackageJson, runEditPackageJsonAll, runSetupGithubActions, @@ -48,6 +51,70 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) +describe('readWorkspacePatterns', () => { + it('reads pnpm workspace patterns', () => { + writePkg({ private: true }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n - "./apps/*"\n', + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('reads package.json workspaces for npm, yarn, and bun style repos', () => { + writePkg({ private: true, workspaces: ['packages/*', './apps/*'] }) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('reads deno workspace patterns from deno.json', () => { + writeFileSync( + join(root, 'deno.json'), + JSON.stringify({ workspace: ['packages/*', './mods/*'] }, null, 2), + ) + + expect(readWorkspacePatterns(root)).toEqual(['mods/*', 'packages/*']) + }) + + it('reads deno workspace patterns from deno.jsonc', () => { + writeFileSync( + join(root, 'deno.jsonc'), + [ + '{', + ' // Workspace packages', + ' "workspace": ["./packages/*", "apps/*",],', + '}', + ].join('\n'), + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) +}) + +describe('workspace package resolution', () => { + it('resolves nested workspace patterns and finds skill-bearing packages', () => { + writePkg({ private: true, workspaces: ['apps/*/packages/*'] }) + + const pkgDir = join(root, 'apps', 'web', 'packages', 'router') + const skillDir = join(pkgDir, 'skills', 'routing', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@test/router' }, null, 2), + ) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: routing/core\ndescription: test\n---\n# Routing\n', + ) + + expect(resolveWorkspacePackages(root, ['apps/*/packages/*'])).toEqual([ + pkgDir, + ]) + expect(findPackagesWithSkills(root)).toEqual([pkgDir]) + }) +}) + describe('runEditPackageJson', () => { it('adds skills and !skills/_artifacts to files array', () => { writePkg({ name: 'test-pkg', files: ['dist', 'src'] }, 2) @@ -185,6 +252,35 @@ describe('runEditPackageJson', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + it('skips !skills/_artifacts in pnpm monorepo packages (pnpm-workspace.yaml)', () => { + const monoRoot = mkdtempSync(join(tmpdir(), 'pnpm-mono-')) + const pkgDir = join(monoRoot, 'packages', 'my-lib') + mkdirSync(pkgDir, { recursive: true }) + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify({ private: true }), + ) + writeFileSync( + join(monoRoot, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ) + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@scope/my-lib', files: ['dist'] }, null, 2), + ) + + const result = runEditPackageJson(pkgDir) + expect(result.added).toContain('files: "skills"') + expect(result.added).not.toEqual( + expect.arrayContaining([expect.stringContaining('!skills/_artifacts')]), + ) + + const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) + expect(pkg.files).not.toContain('!skills/_artifacts') + + rmSync(monoRoot, { recursive: true, force: true }) + }) + it('preserves 4-space indentation', () => { writeFileSync( join(root, 'package.json'), @@ -216,7 +312,7 @@ describe('runSetupGithubActions', () => { ) expect(wfContent).toContain('package: @tanstack/query') expect(wfContent).toContain('repo: TanStack/query') - expect(wfContent).toContain('paths:') + expect(wfContent).toContain('paths:\n') expect(wfContent).toContain("'docs/**'") }) @@ -244,6 +340,49 @@ describe('runSetupGithubActions', () => { expect(result.workflows).toHaveLength(0) }) + it('uses repo as label for private monorepo roots instead of "root"', () => { + const monoRoot = mkdtempSync(join(tmpdir(), 'label-test-')) + const pkgDir = join(monoRoot, 'packages', 'my-lib') + const skillDir = join(pkgDir, 'skills', 'core', 'setup') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core/setup\ndescription: test\n---\n# Setup\n', + ) + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + repository: { + type: 'git', + url: 'git+https://github.com/TestOrg/my-repo.git', + }, + }), + ) + writeFileSync( + join(monoRoot, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ) + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: '@testorg/my-lib' }), + ) + mkdirSync(join(pkgDir, 'src'), { recursive: true }) + + const result = runSetupGithubActions(monoRoot, metaDir) + expect(result.workflows.length).toBeGreaterThan(0) + + const notifyContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: TestOrg/my-repo') + expect(notifyContent).not.toContain('package: root') + + rmSync(monoRoot, { recursive: true, force: true }) + }) + it('writes workflows to the workspace root with monorepo-aware substitutions', () => { const monoRoot = createMonorepo({ packages: [ @@ -308,6 +447,171 @@ describe('runSetupGithubActions', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + + it('treats workspace roots without package skills as monorepos', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + writeFileSync( + join(metaDir, 'templates', 'workflows', 'validate-skills.yml'), + 'for dir in {{WORKSPACE_SKILL_GLOBS}}; do\n echo "$dir"\ndone\n', + ) + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'root', + private: true, + repository: { + type: 'git', + url: 'git+https://github.com/TestOrg/my-repo.git', + }, + }, + null, + 2, + ), + ) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n - "examples/react/*"\n', + ) + mkdirSync(join(root, 'docs'), { recursive: true }) + mkdirSync(join(root, 'packages', 'react-router', 'src'), { + recursive: true, + }) + writeFileSync( + join(root, 'packages', 'react-router', 'package.json'), + JSON.stringify({ name: '@testorg/react-router' }, null, 2), + ) + mkdirSync(join(root, 'examples', 'react', 'basic', 'src'), { + recursive: true, + }) + writeFileSync( + join(root, 'examples', 'react', 'basic', 'package.json'), + JSON.stringify({ name: '@testorg/example-basic' }, null, 2), + ) + + const result = runSetupGithubActions(root, metaDir) + const output = logSpy.mock.calls.flat().join('\n') + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(root, '.github', 'workflows', 'check-skills.yml'), + join(root, '.github', 'workflows', 'notify-intent.yml'), + join(root, '.github', 'workflows', 'validate-skills.yml'), + ]), + ) + expect(output).toContain('Package: TestOrg/my-repo') + expect(output).toContain('Mode: monorepo (0 packages with skills)') + + const notifyContent = readFileSync( + join(root, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: TestOrg/my-repo') + expect(notifyContent).toContain('paths:\n') + expect(notifyContent).toContain("- 'docs/**'") + expect(notifyContent).toContain("- 'examples/react/*/src/**'") + expect(notifyContent).toContain("- 'packages/*/src/**'") + expect(notifyContent).not.toContain('packages/root/src/**') + + const checkContent = readFileSync( + join(root, '.github', 'workflows', 'check-skills.yml'), + 'utf8', + ) + expect(checkContent).toContain('label: TestOrg/my-repo') + + logSpy.mockRestore() + }) + + it('writes monorepo workflows to the workspace root even without package skills', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'root', + private: true, + repository: 'https://github.com/TestOrg/my-repo', + workspaces: ['packages/*'], + }, + null, + 2, + ), + ) + mkdirSync(join(root, 'packages', 'react-router', 'src'), { + recursive: true, + }) + writeFileSync( + join(root, 'packages', 'react-router', 'package.json'), + JSON.stringify({ name: '@testorg/react-router' }, null, 2), + ) + + const result = runSetupGithubActions( + join(root, 'packages', 'react-router'), + metaDir, + ) + const output = logSpy.mock.calls.flat().join('\n') + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(root, '.github', 'workflows', 'check-skills.yml'), + join(root, '.github', 'workflows', 'notify-intent.yml'), + ]), + ) + expect( + existsSync( + join(root, 'packages', 'react-router', '.github', 'workflows'), + ), + ).toBe(false) + expect(output).toContain('Mode: monorepo (0 packages with skills)') + + logSpy.mockRestore() + }) + + it('copies validate workflow with monorepo skill discovery commands', () => { + writeFileSync( + join(metaDir, 'templates', 'workflows', 'validate-skills.yml'), + [ + 'run: |', + ' shopt -s globstar 2>/dev/null || true', + ' FOUND=false', + ' if [ -d "skills" ]; then', + ' intent validate skills', + ' FOUND=true', + ' fi', + ' for dir in {{WORKSPACE_SKILL_GLOBS}}; do', + ' if [ -d "$dir" ]; then', + ' intent validate "$dir"', + ' FOUND=true', + ' fi', + ' done', + ].join('\n'), + ) + + writePkg({ + name: '@tanstack/router', + private: true, + workspaces: ['packages/*', 'examples/react/*'], + }) + + const result = runSetupGithubActions(root, metaDir) + expect(result.workflows).toContain( + join(root, '.github', 'workflows', 'validate-skills.yml'), + ) + + const content = readFileSync( + join(root, '.github', 'workflows', 'validate-skills.yml'), + 'utf8', + ) + expect(content).toContain('FOUND=false') + expect(content).toContain('globstar') + expect(content).toContain('intent validate skills') + expect(content).toContain('examples/react/*/skills') + expect(content).toContain('packages/*/skills') + expect(content).toContain('intent validate "$dir"') + expect(content).not.toContain('for dir in */*/skills; do') + }) }) // ---------------------------------------------------------------------------