diff --git a/.changeset/getmetadir-walk-up.md b/.changeset/getmetadir-walk-up.md new file mode 100644 index 0000000..7cd1932 --- /dev/null +++ b/.changeset/getmetadir-walk-up.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix `setup`, `setup-github-actions`, `meta`, and `scaffold` failing with "No templates directory found" / "Meta-skills directory not found" in the published package. `getMetaDir` hardcoded `../../meta`, which is correct for the `src/commands/` source layout but overshoots to `@tanstack/meta` from the flat `dist/` layout the build ships. Walk up to the first `meta/` directory instead so resolution works in both layouts (and symlinked installs). diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index 5304d8c..d6ea381 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { fail } from '../shared/cli-error.js' @@ -28,8 +28,36 @@ export interface StaleTargetResult { export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 3 export function getMetaDir(): string { - const thisDir = dirname(fileURLToPath(import.meta.url)) - return join(thisDir, '..', '..', 'meta') + return findMetaDir(dirname(fileURLToPath(import.meta.url))) +} + +/** + * Resolve the package `meta/` directory by walking up from `startDir`. + * + * The CLI module sits at `src/commands/` in source but is bundled flat into + * `dist/` in the published package, so the depth from the module to the + * package root differs between the two layouts. A hardcoded `..` count is + * correct for only one of them and breaks `setup` / `meta` / `scaffold` in the + * other. Walking up to the first `meta/` directory handles both layouts (and + * symlinked installs such as pnpm) without depending on the build output + * depth. Falls back to the historical `../../meta` resolution if no `meta/` + * is found, so behaviour is never worse than before. + */ +export function findMetaDir(startDir: string): string { + let dir = startDir + // Sanity cap: a package is never this many dirs deep; the root check below + // also stops the walk at the filesystem root. + for (let limit = 0; limit < 10; limit++) { + const candidate = join(dir, 'meta') + // Check it's a directory, not just that something named `meta` exists — a + // stray file named `meta` in an ancestor must not short-circuit the walk. + if (existsSync(candidate) && statSync(candidate).isDirectory()) + return candidate + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return join(startDir, '..', '..', 'meta') } export function getCheckSkillsWorkflowAdvisories(root: string): Array { diff --git a/packages/intent/tests/support.test.ts b/packages/intent/tests/support.test.ts new file mode 100644 index 0000000..f604257 --- /dev/null +++ b/packages/intent/tests/support.test.ts @@ -0,0 +1,102 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { findMetaDir, getMetaDir } from '../src/commands/support.js' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'support-test-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function seedMeta(pkgDir: string): string { + const metaDir = join(pkgDir, 'meta', 'templates', 'workflows') + mkdirSync(metaDir, { recursive: true }) + writeFileSync( + join(metaDir, 'check-skills.yml'), + '# intent-workflow-version: 3\n', + ) + return join(pkgDir, 'meta') +} + +describe('findMetaDir', () => { + it('resolves from the flat dist/ layout used by the published package', () => { + const pkgDir = join(root, 'pkg') + const metaDir = seedMeta(pkgDir) + // Bundled module lives flat at /dist/support-*.mjs. + const startDir = join(pkgDir, 'dist') + mkdirSync(startDir, { recursive: true }) + expect(findMetaDir(startDir)).toBe(metaDir) + }) + + it('resolves from the deep src/commands/ layout used in source', () => { + const pkgDir = join(root, 'pkg') + const metaDir = seedMeta(pkgDir) + // Source module lives at /src/commands/support.ts (two levels deep). + const startDir = join(pkgDir, 'src', 'commands') + mkdirSync(startDir, { recursive: true }) + expect(findMetaDir(startDir)).toBe(metaDir) + }) + + it('falls back to the historical ../../meta when no meta/ is found', () => { + const startDir = join(root, 'pkg', 'dist') + mkdirSync(startDir, { recursive: true }) + expect(findMetaDir(startDir)).toBe(join(startDir, '..', '..', 'meta')) + expect(existsSync(findMetaDir(startDir))).toBe(false) + }) + + it('skips a stray file named meta and keeps walking', () => { + const pkgDir = join(root, 'pkg') + const metaDir = seedMeta(pkgDir) + const startDir = join(pkgDir, 'dist') + mkdirSync(startDir, { recursive: true }) + // A file (not a directory) named `meta` next to startDir must not short-circuit. + writeFileSync(join(startDir, 'meta'), '') + expect(findMetaDir(startDir)).toBe(metaDir) + }) + + it('resolves through a symlinked install path (pnpm-style)', () => { + // Real package lives in a pnpm-style store; the consumer reaches it via a symlink. + const storePkg = join( + root, + 'store', + '@tanstack+intent', + 'node_modules', + '@tanstack', + 'intent', + ) + seedMeta(storePkg) + mkdirSync(join(storePkg, 'dist'), { recursive: true }) + + const consumerTanstack = join(root, 'consumer', 'node_modules', '@tanstack') + mkdirSync(consumerTanstack, { recursive: true }) + symlinkSync(storePkg, join(consumerTanstack, 'intent'), 'dir') + + const startDir = join(consumerTanstack, 'intent', 'dist') + const resolved = findMetaDir(startDir) + expect(resolved).toBe(join(consumerTanstack, 'intent', 'meta')) + expect(existsSync(resolved)).toBe(true) + }) +}) + +describe('getMetaDir', () => { + it('resolves to a meta/ directory that ships the workflow template', () => { + const metaDir = getMetaDir() + expect(existsSync(metaDir)).toBe(true) + expect( + existsSync(join(metaDir, 'templates', 'workflows', 'check-skills.yml')), + ).toBe(true) + }) +})