import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import type { PlopTypes } from "@turbo/gen"; /** * Throws if a core package directory already exists. Used as the first action * in every per-package generator so re-running is safe. */ export function assertOptionalPackageNotPresent( name: string, cwd: string = process.cwd(), ): void { const pkgRoot = join(cwd, "packages", name); if (existsSync(pkgRoot)) { throw new Error( `packages/${name}/ already exists — refusing to scaffold (delete it first if intentional)`, ); } } /** * Inserts a package name into the transpilePackages array of a Next.js config * file, preserving alphabetical order. Idempotent. */ export function addToTranspilePackages( nextConfigPath: string, pkgName: string, ): void { const source = readFileSync(nextConfigPath, "utf8"); if (source.includes(`"${pkgName}"`)) return; const updated = source.replace( /(transpilePackages:\s*\[\s*)([\s\S]*?)(\s*\])/, (_match, open: string, body: string, close: string) => { const entries = body .split(",") .map((e) => e.trim()) .filter(Boolean); entries.push(`"${pkgName}"`); entries.sort(); const formatted = entries.map((e) => ` ${e}`).join(",\n"); return `${open}\n${formatted},${close}`; }, ); writeFileSync(nextConfigPath, updated); } /** * Inserts a code block immediately after a `// ` anchor in a file. * Idempotent: refuses to insert if the exact block already follows the anchor. */ export function splicePluginRulesAt( filePath: string, anchorName: string, block: string, ): void { const source = readFileSync(filePath, "utf8"); const anchor = `// `; const idx = source.indexOf(anchor); if (idx === -1) { throw new Error(`Anchor ${anchor} not found in ${filePath}`); } const after = source.slice(idx + anchor.length); if (after.trimStart().startsWith(block.trim())) return; // idempotent const updated = source.slice(0, idx + anchor.length) + "\n" + block + after; writeFileSync(filePath, updated); } /** * Inserts an import line immediately after the matching anchor. Idempotent. */ export function splicePluginImportsAt( filePath: string, anchorName: string, importLine: string, ): void { splicePluginRulesAt(filePath, anchorName, importLine); } /** * Inserts a `{ type, pattern, ...opts }` entry into the boundaries/elements * array of `core-eslint/base.js`, placed immediately BEFORE the * `packages/core-*` wildcard so its more-specific match wins. Idempotent. */ export function addBoundariesEntry( baseJsPath: string, packagePath: string, opts: { mode?: "folder" } = {}, ): void { const source = readFileSync(baseJsPath, "utf8"); if (source.includes(`pattern: "${packagePath}"`)) return; // idempotent const wildcardLine = source.match( /(\s*\{\s*type:\s*"core",\s*pattern:\s*"packages\/core-\*"[^}]*\},)/, ); if (!wildcardLine) { throw new Error(`packages/core-* wildcard not found in ${baseJsPath}`); } const modeFragment = opts.mode ? `, mode: "${opts.mode}"` : ""; const newEntry = ` { type: "core", pattern: "${packagePath}"${modeFragment} },\n`; const updated = source.replace(wildcardLine[0], `\n${newEntry}${wildcardLine[1]}`); writeFileSync(baseJsPath, updated); } /** * Walks turbo/generators/templates// recursively. For each .hbs * file, returns a plop `add` action that emits the file (without .hbs * extension) at /. The actions are sorted so * directory creation is deterministic. */ export function emitTemplateTree( srcPrefix: string, destPrefix: string, opts: { templatesRoot?: string } = {}, ): PlopTypes.AddActionConfig[] { // The templates directory is resolved in priority order: // 1. opts.templatesRoot — test injection (temp directory) // 2. cwd/turbo/generators/templates — turbo gen context (cwd = repo root) // 3. cwd/templates — vitest context (cwd = turbo/generators) let root: string; if (opts.templatesRoot) { root = opts.templatesRoot; } else { const fromRepoRoot = join(process.cwd(), "turbo", "generators", "templates"); const fromGeneratorsDir = join(process.cwd(), "templates"); root = existsSync(fromRepoRoot) ? fromRepoRoot : fromGeneratorsDir; } const srcRoot = join(root, srcPrefix); const out: PlopTypes.AddActionConfig[] = []; walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out); out.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? "")); return out; } function walkHbs( topRoot: string, dir: string, srcPrefix: string, destPrefix: string, out: PlopTypes.AddActionConfig[], ): void { for (const name of readdirSync(dir)) { const full = join(dir, name); if (statSync(full).isDirectory()) { walkHbs(topRoot, full, srcPrefix, destPrefix, out); continue; } if (!name.endsWith(".hbs")) continue; const rel = relative(topRoot, full).replace(/\.hbs$/, ""); out.push({ type: "add", path: join(destPrefix, rel).replace(/\\/g, "/"), templateFile: join("templates", srcPrefix, relative(topRoot, full)).replace(/\\/g, "/"), } as PlopTypes.AddActionConfig); } }