diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 9e391a26..7896322e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # rafters +## Unreleased + +### Minor Changes + +- feat(config): multi-folder paths for components, primitives, composites, rules (#1420). Each path field in `.rafters/config.rafters.json` now accepts either the existing single string or an array of entries, so a project can read assets from external folders (e.g. `@shingle/shared`) on top of its own. Entries are plain strings or `{ path, root: true }` objects; the install root for `rafters add` is the entry tagged `{ root: true }`, otherwise the first entry whose realpath resolves inside cwd, otherwise the framework default at cwd. Local always wins on collision -- the install root is always first in the read set so first-write-wins semantics in loaders produce local-wins reads. New `rulesPath` field added with the same semantics. New helpers `resolveRoot` and `resolveReadSet` in `utils/paths.ts`. MCP composite loader now iterates the resolved read set instead of a hardcoded `.rafters/composites` path, so shared composite packages are queryable through `rafters_composite`. + ## 0.0.53 ### Minor Changes diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 39db085c..331a2b57 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -20,7 +20,7 @@ import { type InstallRegistryDepsResult, installRegistryDependencies, } from '../utils/install-registry-deps.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, type PathField, resolveRoot } from '../utils/paths.js'; import { error, log, setAgentMode } from '../utils/ui.js'; import type { RaftersConfig } from './init.js'; @@ -170,53 +170,53 @@ export function isAlreadyInstalled(config: RaftersConfig | null, item: RegistryI */ export function trackInstalled(config: RaftersConfig, items: RegistryItem[]): void { if (!config.installed) { - config.installed = { components: [], primitives: [], composites: [] }; - } - if (!config.installed.composites) { - config.installed.composites = []; + config.installed = { components: [], primitives: [], composites: [], rules: [] }; } + const installed = config.installed; + if (!installed.composites) installed.composites = []; + if (!installed.rules) installed.rules = []; for (const item of items) { - if (item.type === 'ui') { - if (!config.installed.components.includes(item.name)) { - config.installed.components.push(item.name); - } - } else if (item.type === 'composite') { - if (!config.installed.composites.includes(item.name)) { - config.installed.composites.push(item.name); - } - } else { - if (!config.installed.primitives.includes(item.name)) { - config.installed.primitives.push(item.name); - } - } + const bucket = + item.type === 'ui' + ? installed.components + : item.type === 'composite' + ? installed.composites + : installed.primitives; + if (!bucket.includes(item.name)) bucket.push(item.name); } - config.installed.components.sort(); - config.installed.primitives.sort(); - config.installed.composites.sort(); + installed.components.sort(); + installed.primitives.sort(); + installed.composites.sort(); + installed.rules.sort(); +} + +/** + * Resolve the install root for a config path field. Path fields accept a + * single string or an array of entries; this returns the relative folder + * `rafters add` should write into. See {@link resolveRoot} for precedence. + */ +function rootFor(field: PathField | undefined, cwd: string, fallback: string): string { + return field === undefined ? fallback : resolveRoot(field, cwd, fallback); } /** * Transform registry path to project path based on config * e.g., "components/ui/button.tsx" -> "app/components/ui/button.tsx" */ -function transformPath(registryPath: string, config: RaftersConfig | null): string { +function transformPath(registryPath: string, config: RaftersConfig | null, cwd: string): string { if (!config) return registryPath; - // Transform component paths - if (registryPath.startsWith('components/ui/')) { - return registryPath.replace('components/ui/', `${config.componentsPath}/`); - } - - // Transform primitive paths - if (registryPath.startsWith('lib/primitives/')) { - return registryPath.replace('lib/primitives/', `${config.primitivesPath}/`); - } - - // Transform composite paths - if (registryPath.startsWith('composites/')) { - return registryPath.replace('composites/', `${config.compositesPath}/`); + const replacements: Array<[string, PathField, string]> = [ + ['components/ui/', config.componentsPath, 'components/ui'], + ['lib/primitives/', config.primitivesPath, 'lib/primitives'], + ['composites/', config.compositesPath, 'composites'], + ['rules/', config.rulesPath, 'rules'], + ]; + for (const [prefix, field, fallback] of replacements) { + if (registryPath.startsWith(prefix)) { + return registryPath.replace(prefix, `${rootFor(field, cwd, fallback)}/`); + } } - return registryPath; } @@ -240,12 +240,13 @@ export function transformFileContent( content: string, config: RaftersConfig | null, fileType: 'component' | 'primitive' = 'component', + cwd: string = process.cwd(), ): string { let transformed = content; // Get paths from config or use defaults - const componentsPath = config?.componentsPath ?? 'components/ui'; - const primitivesPath = config?.primitivesPath ?? 'lib/primitives'; + const componentsPath = rootFor(config?.componentsPath, cwd, 'components/ui'); + const primitivesPath = rootFor(config?.primitivesPath, cwd, 'lib/primitives'); // Strip source root prefix (src/, app/) for @/ alias imports. // Config paths are filesystem paths (src/components/ui) but @/ alias @@ -329,7 +330,7 @@ async function installItem( for (const file of filesToInstall) { // Transform the path based on project config - const projectPath = transformPath(file.path, config); + const projectPath = transformPath(file.path, config, cwd); const targetPath = join(cwd, projectPath); // Check if file exists and handle overwrite @@ -351,7 +352,7 @@ async function installItem( // Transform and write the file const fileType = item.type === 'primitive' ? 'primitive' : 'component'; - const transformedContent = transformFileContent(file.content, config, fileType); + const transformedContent = transformFileContent(file.content, config, fileType, cwd); await writeFile(targetPath, transformedContent, 'utf-8'); installedFiles.push(projectPath); @@ -646,10 +647,11 @@ export async function add(componentArgs: string[], options: AddOptions): Promise componentsPath: 'components/ui', primitivesPath: 'lib/primitives', compositesPath: 'composites', + rulesPath: 'rules', cssPath: null, shadcn: false, exports: DEFAULT_EXPORTS, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; trackInstalled(newConfig, installedItems); await saveConfig(cwd, newConfig); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1a7e196b..7fc539c8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -39,7 +39,7 @@ import { FUTURE_EXPORTS, selectionsToConfig, } from '../utils/exports.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, type PathField } from '../utils/paths.js'; import { isAgentMode, log, setAgentMode } from '../utils/ui.js'; import { updateDependencies } from '../utils/update-dependencies.js'; @@ -83,42 +83,58 @@ const CSS_LOCATIONS: Record = { }; // Default component paths per framework -const COMPONENT_PATHS: Record< +export const COMPONENT_PATHS: Record< Framework, - { components: string; primitives: string; composites: string } + { components: string; primitives: string; composites: string; rules: string } > = { astro: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', + }, + next: { + components: 'components/ui', + primitives: 'lib/primitives', + composites: 'composites', + rules: 'rules', }, - next: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' }, vite: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', }, remix: { components: 'app/components/ui', primitives: 'app/lib/primitives', composites: 'app/composites', + rules: 'app/rules', }, 'react-router': { components: 'app/components/ui', primitives: 'app/lib/primitives', composites: 'app/composites', + rules: 'app/rules', }, wc: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', }, vanilla: { components: 'src/components/ui', primitives: 'src/lib/primitives', composites: 'src/composites', + rules: 'src/rules', + }, + unknown: { + components: 'components/ui', + primitives: 'lib/primitives', + composites: 'composites', + rules: 'rules', }, - unknown: { components: 'components/ui', primitives: 'lib/primitives', composites: 'composites' }, }; /** @@ -136,34 +152,29 @@ const FRAMEWORK_PROMPT_LABELS: Record, string> = { /** * Configuration persisted in `.rafters/config.rafters.json`. - * Used by the CLI to resolve framework-specific defaults and perform - * path transformations when generating or updating files. - * All paths are relative to the project root. + * + * Path fields accept either a single string (status quo) or an array of + * entries to support multi-folder layouts (e.g. project + `@shingle/shared`). + * When multiple entries are provided, the install root is the entry tagged + * `{ root: true }`, otherwise the first entry whose realpath resolves inside + * cwd. Local entries always win on collision. */ export interface RaftersConfig { - /** Detected or selected application framework */ framework: Framework; - /** Which file variant to install: react (.tsx), astro (.astro), vue (.vue), svelte (.svelte) */ componentTarget?: ComponentTarget; - /** Root directory for UI components, e.g. `components/ui` or `app/components/ui` */ - componentsPath: string; - /** Root directory for primitive components, e.g. `lib/primitives` */ - primitivesPath: string; - /** Root directory for composite JSON files, e.g. `src/composites` */ - compositesPath: string; - /** Entry CSS file for design tokens, or null if not detected */ + componentsPath: PathField; + primitivesPath: PathField; + compositesPath: PathField; + rulesPath: PathField; cssPath: string | null; - /** Whether shadcn/ui was detected in the project */ shadcn: boolean; - /** Export format selections */ exports: ExportConfig; - /** Dark mode strategy: 'class' (default, .dark class toggle) or 'media' (OS preference) */ darkMode?: 'class' | 'media'; - /** Items installed via `rafters add` */ installed?: { components: string[]; primitives: string[]; composites: string[]; + rules: string[]; }; } @@ -416,6 +427,7 @@ async function regenerateFromExisting( existingConfig.componentsPath = frameworkPaths.components; existingConfig.primitivesPath = frameworkPaths.primitives; existingConfig.compositesPath = frameworkPaths.composites; + existingConfig.rulesPath = frameworkPaths.rules; } // Load all tokens from .rafters/tokens/ @@ -469,10 +481,11 @@ async function regenerateFromExisting( componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: null, shadcn: !!shadcn, exports, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; await writeFile(paths.config, JSON.stringify(newConfig, null, 2)); } @@ -509,6 +522,7 @@ async function resetToDefaults( existingConfig.componentsPath = frameworkPaths.components; existingConfig.primitivesPath = frameworkPaths.primitives; existingConfig.compositesPath = frameworkPaths.composites; + existingConfig.rulesPath = frameworkPaths.rules; } // Load existing tokens to check for userOverride backups @@ -598,10 +612,11 @@ async function resetToDefaults( componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: null, shadcn: !!shadcn, exports, - installed: { components: [], primitives: [], composites: [] }, + installed: { components: [], primitives: [], composites: [], rules: [] }, }; await writeFile(paths.config, JSON.stringify(newConfig, null, 2)); } @@ -869,6 +884,7 @@ export async function init(options: InitOptions): Promise { componentsPath: frameworkPaths.components, primitivesPath: frameworkPaths.primitives, compositesPath: frameworkPaths.composites, + rulesPath: frameworkPaths.rules, cssPath: detectedCssPath, shadcn: !!shadcn, exports, @@ -876,6 +892,7 @@ export async function init(options: InitOptions): Promise { components: [], primitives: [], composites: [], + rules: [], }, }; await writeFile(paths.config, JSON.stringify(config, null, 2)); diff --git a/packages/cli/src/mcp/tools.ts b/packages/cli/src/mcp/tools.ts index 455cee4f..5c2c97ee 100644 --- a/packages/cli/src/mcp/tools.ts +++ b/packages/cli/src/mcp/tools.ts @@ -12,7 +12,7 @@ * Token import lives in `rafters init` / `rafters import`, not MCP. */ -import { readdir } from 'node:fs/promises'; +import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { @@ -24,8 +24,9 @@ import { registerComposite, searchComposites, } from '@rafters/composites'; +import type { RaftersConfig } from '../commands/init.js'; import { registryClient } from '../registry/client.js'; -import { getRaftersPaths } from '../utils/paths.js'; +import { getRaftersPaths, resolveReadSet } from '../utils/paths.js'; import { resolveWorkspace, type Workspace } from '../utils/workspaces.js'; const WORKSPACE_PARAM = { @@ -258,12 +259,34 @@ export class RaftersToolHandler { } if (workspace && !this.compositesLoadedFor.has(workspace.root)) { - const paths = getRaftersPaths(workspace.root); - await this.loadCompositesFromDir(join(paths.root, 'composites')); + for (const dir of await this.compositeReadRoots(workspace.root)) { + await this.loadCompositesFromDir(dir); + } this.compositesLoadedFor.add(workspace.root); } } + /** + * Resolve the set of folders to scan for composite manifests in a workspace. + * Reads `.rafters/config.rafters.json` and applies the workspace's + * `compositesPath` (which may be a string or an array of entries to support + * shared packages like `@shingle/shared`). Falls back to `.rafters/composites` + * when no config or compositesPath is set. + */ + private async compositeReadRoots(workspaceRoot: string): Promise { + const paths = getRaftersPaths(workspaceRoot); + let config: RaftersConfig | null = null; + try { + config = JSON.parse(await readFile(paths.config, 'utf-8')) as RaftersConfig; + } catch { + // No config -- fall through to default + } + if (!config?.compositesPath) { + return [join(paths.root, 'composites')]; + } + return resolveReadSet(config.compositesPath, workspaceRoot); + } + private async handleComposite(args: Record): Promise { const { id, query, category, workspace } = args as { id?: string; diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts index 77257487..64e97a44 100644 --- a/packages/cli/src/utils/paths.ts +++ b/packages/cli/src/utils/paths.ts @@ -2,7 +2,9 @@ * Path utilities for .rafters/ directory structure */ -import { join } from 'node:path'; +import { realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { z } from 'zod'; export interface RaftersPaths { root: string; @@ -42,3 +44,103 @@ export function getOutputFilePath( ): string { return join(projectRoot, '.rafters', 'output', filename); } + +/** + * A single entry in a path field. Either a plain path string, or an object + * with `root: true` to mark the install target explicitly. Used for fields + * that accept multiple folders (componentsPath, primitivesPath, compositesPath, + * rulesPath). + */ +export const PathEntrySchema = z.union([ + z.string(), + z.object({ path: z.string(), root: z.literal(true).optional() }), +]); + +export type PathEntry = z.infer; + +/** + * A path field accepts a single string (status quo) or an array of entries. + */ +export const PathFieldSchema = z.union([z.string(), z.array(PathEntrySchema)]); + +export type PathField = z.infer; + +function entryPath(entry: PathEntry): string { + return typeof entry === 'string' ? entry : entry.path; +} + +function entryHasExplicitRoot(entry: PathEntry): boolean { + return typeof entry === 'object' && entry.root === true; +} + +function tryRealpath(absPath: string): string { + try { + return realpathSync(absPath); + } catch { + return absPath; + } +} + +function isInsideCwd(absPath: string, cwdReal: string): boolean { + const rel = relative(cwdReal, absPath); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +/** + * Resolve the install root for a path field. + * + * Precedence: + * 1. The first entry with `{ root: true }` + * 2. The first entry whose realpath resolves inside cwd + * 3. `fallback` joined to cwd (the framework default) + * + * Returns a path **relative to cwd** so it can be persisted in config and + * compared with the other resolved paths consistently. + */ +export function resolveRoot(field: PathField, cwd: string, fallback: string): string { + const cwdReal = tryRealpath(resolve(cwd)); + + if (typeof field === 'string') { + return field; + } + + for (const entry of field) { + if (entryHasExplicitRoot(entry)) { + return entryPath(entry); + } + } + + for (const entry of field) { + const p = entryPath(entry); + const abs = isAbsolute(p) ? p : resolve(cwdReal, p); + if (isInsideCwd(tryRealpath(abs), cwdReal)) return p; + } + + return fallback; +} + +/** + * Resolve the read set for a path field: the ordered list of absolute roots + * to search when loading items. The install root (per {@link resolveRoot}) is + * always first, so first-write-wins semantics in the loader produce + * "local wins on collision". Realpathed and deduplicated. + */ +export function resolveReadSet(field: PathField, cwd: string, fallback?: string): string[] { + const cwdReal = tryRealpath(resolve(cwd)); + const entries = typeof field === 'string' ? [field] : field.map(entryPath); + const root = resolveRoot(field, cwd, fallback ?? entries[0] ?? ''); + const ordered = [root, ...entries.filter((e) => e !== root)]; + + const seen = new Set(); + const out: string[] = []; + for (const entry of ordered) { + if (!entry) continue; + const abs = isAbsolute(entry) ? entry : resolve(cwdReal, entry); + const real = tryRealpath(abs); + if (!seen.has(real)) { + seen.add(real); + out.push(real); + } + } + return out; +} diff --git a/packages/cli/test/integration/init.integration.test.ts b/packages/cli/test/integration/init.integration.test.ts index 42c13021..163eb8db 100644 --- a/packages/cli/test/integration/init.integration.test.ts +++ b/packages/cli/test/integration/init.integration.test.ts @@ -47,6 +47,7 @@ describe('rafters init - fresh initialization', () => { components: [], primitives: [], composites: [], + rules: [], }); }, 30000); diff --git a/packages/cli/test/utils/paths.test.ts b/packages/cli/test/utils/paths.test.ts index 4e80ea4a..e8236b72 100644 --- a/packages/cli/test/utils/paths.test.ts +++ b/packages/cli/test/utils/paths.test.ts @@ -1,6 +1,14 @@ -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { getOutputFilePath, getRaftersPaths, getTokenFilePath } from '../../src/utils/paths.js'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getOutputFilePath, + getRaftersPaths, + getTokenFilePath, + resolveReadSet, + resolveRoot, +} from '../../src/utils/paths.js'; describe('getRaftersPaths', () => { it('should return correct paths for project root', () => { @@ -60,3 +68,70 @@ describe('getOutputFilePath', () => { ); }); }); + +describe('resolveRoot', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'rafters-paths-')); + mkdirSync(join(tmp, 'src/composites'), { recursive: true }); + mkdirSync(join(tmp, 'src/legacy'), { recursive: true }); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns a single string field as-is', () => { + expect(resolveRoot('src/composites', tmp, 'composites')).toBe('src/composites'); + }); + + it('picks the entry tagged { root: true }', () => { + const field = [ + 'src/composites', + { path: 'src/legacy', root: true as const }, + '../shared/composites', + ]; + expect(resolveRoot(field, tmp, 'composites')).toBe('src/legacy'); + }); + + it('picks the first entry that resolves inside cwd when no explicit root', () => { + const field = ['../shared/composites', 'src/composites']; + expect(resolveRoot(field, tmp, 'composites')).toBe('src/composites'); + }); + + it('falls back when zero entries resolve inside cwd', () => { + const field = ['../shared/composites', '/abs/external/composites']; + expect(resolveRoot(field, tmp, 'composites')).toBe('composites'); + }); +}); + +describe('resolveReadSet', () => { + let tmp: string; + let real: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'rafters-paths-')); + mkdirSync(join(tmp, 'src/composites'), { recursive: true }); + mkdirSync(join(tmp, 'shared'), { recursive: true }); + real = realpathSync(tmp); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns single string as one absolute entry', () => { + const out = resolveReadSet('src/composites', tmp); + expect(out).toHaveLength(1); + expect(out[0]).toBe(resolve(real, 'src/composites')); + }); + + it('puts the install root first regardless of array order', () => { + const field = ['../shared', 'src/composites']; + const out = resolveReadSet(field, tmp); + expect(out[0]).toBe(resolve(real, 'src/composites')); + expect(out[1]).toBe(resolve(real, '../shared')); + }); + + it('deduplicates entries that realpath to the same location', () => { + const out = resolveReadSet(['src/composites', 'src/composites'], tmp); + expect(out).toHaveLength(1); + }); +});