UNPKG

@tanstack/cli

Version:
436 lines (435 loc) 16.9 kB
import { resolve } from 'node:path'; import fs from 'node:fs'; import { DEFAULT_PACKAGE_MANAGER, finalizeAddOns, getFrameworkById, getPackageManager, getRawRegistry, loadStarter, populateAddOnOptionsDefaults, } from '@tanstack/create'; import { getCurrentDirectoryName, sanitizePackageName, validateProjectName, } from './utils.js'; const SUPPORTED_LEGACY_TEMPLATES = new Set([ 'file-router', 'typescript', 'tsx', ]); const LEGACY_TEMPLATE_ALIASES = new Set(['javascript', 'js', 'jsx']); function getLegacyTemplateValue(templateValue) { if (!templateValue) { return undefined; } const normalized = templateValue.toLowerCase().trim(); if (SUPPORTED_LEGACY_TEMPLATES.has(normalized) || LEGACY_TEMPLATE_ALIASES.has(normalized)) { return normalized; } return undefined; } function slugifyStarterName(value) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } function humanizeStarterId(value) { return value .split(/[-_]/g) .filter(Boolean) .map((part) => part[0].toUpperCase() + part.slice(1)) .join(' '); } function isLikelyStarterUrlOrPath(value) { return (/^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || value.startsWith('./') || value.startsWith('../') || value.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(value)); } function getStarterIdsFromUrl(starterUrl) { const ids = new Set(); try { const pathname = new URL(starterUrl).pathname; const parts = pathname.split('/').filter(Boolean); const lastPart = parts.at(-1)?.toLowerCase(); if (lastPart) { ids.add(lastPart.replace(/\.json$/i, '')); } if ((lastPart === 'starter.json' || lastPart === 'template.json') && parts.length >= 2) { ids.add(parts.at(-2).toLowerCase()); } } catch { // Ignore URL parse errors and rely on other id heuristics. } return ids; } function resolveMonorepoStarterById(starterId, preferredFramework) { const normalized = starterId.toLowerCase().trim(); const idVariants = Array.from(new Set([ normalized, normalized.replace(/-template$/i, ''), normalized.replace(/-starter$/i, ''), ])).filter(Boolean); const cwd = process.cwd(); const rootCandidates = [ cwd, resolve(cwd, '..'), resolve(cwd, '../..'), resolve(cwd, '../../..'), ]; const frameworkOrder = preferredFramework ? [preferredFramework, ...['react', 'solid'].filter((f) => f !== preferredFramework)] : ['react', 'solid']; for (const root of rootCandidates) { for (const framework of frameworkOrder) { for (const id of idVariants) { const templatePath = resolve(root, 'examples', framework, id, 'template.json'); if (fs.existsSync(templatePath)) { return templatePath; } const starterPath = resolve(root, 'examples', framework, id, 'starter.json'); if (fs.existsSync(starterPath)) { return starterPath; } } } } return undefined; } export async function resolveStarterSpecifier(starterSpecifier, preferredFramework) { const normalized = starterSpecifier.trim(); if (!normalized || isLikelyStarterUrlOrPath(normalized)) { return normalized; } const registry = await getRawRegistry(); if (registry && registry.starters?.length) { const lookup = normalized.toLowerCase(); const matches = registry.starters.filter((starter) => { const candidateIds = new Set(); candidateIds.add(starter.name.toLowerCase()); candidateIds.add(slugifyStarterName(starter.name)); for (const id of getStarterIdsFromUrl(starter.url)) { candidateIds.add(id); } return candidateIds.has(lookup); }); const frameworkMatch = preferredFramework ? matches.find((starter) => starter.framework.toLowerCase() === preferredFramework) : undefined; if (frameworkMatch) { return frameworkMatch.url; } if (matches.length > 0) { return matches[0].url; } } const monorepoStarterPath = resolveMonorepoStarterById(normalized, preferredFramework); if (monorepoStarterPath) { return monorepoStarterPath; } if (!registry || !registry.starters?.length) { throw new Error(`Could not resolve template id "${normalized}" because no template registry is configured. Pass a template URL (or set CTA_REGISTRY).`); } const availableIds = Array.from(new Set(registry.starters.flatMap((starter) => { const ids = [slugifyStarterName(starter.name)]; ids.push(...Array.from(getStarterIdsFromUrl(starter.url))); return ids; }))) .filter(Boolean) .sort(); throw new Error(`Unknown template id "${normalized}". Available built-in templates: ${availableIds.join(', ')}`); } export async function listTemplateChoices(preferredFramework) { const frameworkFilter = preferredFramework?.toLowerCase(); const deduped = new Map(); const registry = await getRawRegistry(); for (const starter of registry?.starters || []) { const framework = starter.framework.toLowerCase(); if (frameworkFilter && framework !== frameworkFilter) { continue; } const ids = Array.from(getStarterIdsFromUrl(starter.url)); const id = ids[0] || slugifyStarterName(starter.name); if (!id) { continue; } const key = `${framework}:${id}`; if (!deduped.has(key)) { deduped.set(key, { id, name: starter.name, description: starter.description, framework, }); } } const cwd = process.cwd(); const rootCandidates = [ cwd, resolve(cwd, '..'), resolve(cwd, '../..'), resolve(cwd, '../../..'), ]; const frameworks = frameworkFilter ? [frameworkFilter] : ['react', 'solid']; for (const root of rootCandidates) { for (const framework of frameworks) { const frameworkDir = resolve(root, 'examples', framework); if (!fs.existsSync(frameworkDir) || !fs.statSync(frameworkDir).isDirectory()) { continue; } for (const entry of fs.readdirSync(frameworkDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } const id = entry.name; const key = `${framework}:${id}`; if (deduped.has(key)) { continue; } const templatePath = resolve(frameworkDir, id, 'template.json'); const starterPath = resolve(frameworkDir, id, 'starter.json'); if (!fs.existsSync(templatePath) && !fs.existsSync(starterPath)) { continue; } let name = humanizeStarterId(id); let description; const templateInfoPath = resolve(frameworkDir, id, 'template-info.json'); if (fs.existsSync(templateInfoPath)) { try { const info = JSON.parse(fs.readFileSync(templateInfoPath, 'utf8')); if (info.name) { name = info.name; } description = info.description; } catch { // Ignore malformed template-info files and use fallback values. } } deduped.set(key, { id, name, description, framework, }); } } } return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)); } export function validateLegacyCreateFlags(cliOptions) { const warnings = []; const legacyTemplate = getLegacyTemplateValue(cliOptions.template); if (cliOptions.starter) { warnings.push('The --starter flag is deprecated; prefer --template instead. Backward compatibility remains for now.'); } if (cliOptions.starter && cliOptions.template && !legacyTemplate) { warnings.push('Both --starter and --template were provided. --template takes precedence.'); } if (cliOptions.routerOnly) { warnings.push('The --router-only flag enables router-only compatibility mode. Start-dependent add-ons, deployment adapters, and templates are disabled; only the base template and optional toolchain are supported.'); } if (cliOptions.routerOnly && cliOptions.addOns) { warnings.push('Ignoring --add-ons in router-only compatibility mode.'); } if (cliOptions.routerOnly && cliOptions.deployment) { warnings.push('Ignoring --deployment in router-only compatibility mode.'); } if (cliOptions.routerOnly && cliOptions.starter) { warnings.push('Ignoring --starter/--template in router-only compatibility mode.'); } if (cliOptions.routerOnly && cliOptions.templateId) { warnings.push('Ignoring --template-id in router-only compatibility mode.'); } if (cliOptions.tailwind === true) { warnings.push('The --tailwind flag is deprecated and ignored. Tailwind is always enabled in TanStack Start scaffolds.'); } if (cliOptions.tailwind === false) { warnings.push('The --no-tailwind flag is deprecated and ignored. Tailwind opt-out is intentionally unsupported to keep add-on permutations maintainable; remove Tailwind after scaffolding if needed.'); } if (!legacyTemplate) { return { warnings }; } const template = legacyTemplate; if (template === 'javascript' || template === 'js' || template === 'jsx') { return { warnings, error: 'JavaScript/JSX templates are not supported. TanStack Start file-router templates are TypeScript-only.', }; } if (!SUPPORTED_LEGACY_TEMPLATES.has(template)) { return { warnings, error: `Invalid --template value: ${cliOptions.template}. Supported values are: file-router, typescript, tsx.`, }; } warnings.push('The --template flag is deprecated and mapped for compatibility.'); return { warnings }; } export async function normalizeOptions(cliOptions, forcedAddOns, opts) { let projectName = (cliOptions.projectName ?? '').trim(); let targetDir; // Handle "." as project name - use current directory if (projectName === '.') { projectName = sanitizePackageName(getCurrentDirectoryName()); targetDir = resolve(process.cwd()); } else { targetDir = resolve(process.cwd(), projectName); } if (!projectName && !opts?.disableNameCheck) { return undefined; } if (projectName) { const { valid, error } = validateProjectName(projectName); if (!valid) { console.error(error); process.exit(1); } } // Mode is always file-router (TanStack Start) let mode = 'file-router'; let routerOnly = !!cliOptions.routerOnly; const legacyTemplate = getLegacyTemplateValue(cliOptions.template); if (!cliOptions.starter) { if (cliOptions.template && !legacyTemplate) { cliOptions.starter = cliOptions.template; } else if (cliOptions.templateId) { cliOptions.starter = cliOptions.templateId; } } const template = legacyTemplate; if (template && template !== 'file-router') { routerOnly = true; } if (!cliOptions.starter && cliOptions.templateId) { cliOptions.starter = cliOptions.templateId; } const preferredFramework = (cliOptions.framework || 'react').toLowerCase(); const starter = !routerOnly && cliOptions.starter ? await loadStarter(await resolveStarterSpecifier(cliOptions.starter, preferredFramework)) : undefined; // TypeScript and Tailwind are always enabled with TanStack Start const typescript = true; const tailwind = true; if (starter) { cliOptions.framework = starter.framework; mode = starter.mode; } const framework = getFrameworkById(cliOptions.framework || 'react'); async function selectAddOns() { // Edge case for Windows Powershell if (Array.isArray(cliOptions.addOns) && cliOptions.addOns.length === 1) { const parseSeparatedArgs = cliOptions.addOns[0].split(' '); if (parseSeparatedArgs.length > 1) { cliOptions.addOns = parseSeparatedArgs; } } if (Array.isArray(cliOptions.addOns) || starter?.dependsOn || forcedAddOns || cliOptions.toolchain || cliOptions.deployment) { const selectedAddOns = new Set([ ...(routerOnly ? [] : (starter?.dependsOn || [])), ...(routerOnly ? [] : (forcedAddOns || [])), ]); if (!routerOnly && cliOptions.addOns && Array.isArray(cliOptions.addOns)) { for (const a of cliOptions.addOns) { if (a.toLowerCase() === 'start') { continue; } selectedAddOns.add(a); } } if (cliOptions.toolchain) { selectedAddOns.add(cliOptions.toolchain); } if (!routerOnly && cliOptions.deployment) { selectedAddOns.add(cliOptions.deployment); } if (!routerOnly && !cliOptions.deployment && opts?.forcedDeployment) { selectedAddOns.add(opts.forcedDeployment); } return await finalizeAddOns(framework, mode, Array.from(selectedAddOns)); } return []; } const includeExamples = cliOptions.examples ?? !routerOnly; const chosenAddOnsRaw = await selectAddOns(); const chosenAddOns = includeExamples ? chosenAddOnsRaw : chosenAddOnsRaw.filter((addOn) => addOn.type !== 'example'); // Handle add-on configuration option let addOnOptionsFromCLI = {}; if (cliOptions.addOnConfig) { try { addOnOptionsFromCLI = JSON.parse(cliOptions.addOnConfig); } catch (error) { console.error('Error parsing add-on config:', error); process.exit(1); } } const normalized = { projectName: projectName, targetDir, framework, mode, typescript, tailwind, packageManager: cliOptions.packageManager || getPackageManager() || DEFAULT_PACKAGE_MANAGER, git: cliOptions.git ?? true, install: cliOptions.install, chosenAddOns, addOnOptions: { ...populateAddOnOptionsDefaults(chosenAddOns), ...addOnOptionsFromCLI, }, starter: starter, }; normalized.includeExamples = includeExamples; normalized.envVarValues = {}; return normalized; } export function validateDevWatchOptions(cliOptions) { if (!cliOptions.devWatch) { return { valid: true }; } // Validate watch path exists const watchPath = resolve(process.cwd(), cliOptions.devWatch); if (!fs.existsSync(watchPath)) { return { valid: false, error: `Watch path does not exist: ${watchPath}`, }; } // Validate it's a directory const stats = fs.statSync(watchPath); if (!stats.isDirectory()) { return { valid: false, error: `Watch path is not a directory: ${watchPath}`, }; } // Ensure target directory is specified if (!cliOptions.projectName && !cliOptions.targetDir) { return { valid: false, error: 'Project name or target directory is required for dev watch mode', }; } // Check for framework structure const hasAddOns = fs.existsSync(resolve(watchPath, 'add-ons')); const hasAssets = fs.existsSync(resolve(watchPath, 'assets')); const hasFrameworkJson = fs.existsSync(resolve(watchPath, 'framework.json')); if (!hasAddOns && !hasAssets && !hasFrameworkJson) { return { valid: false, error: `Watch path does not appear to be a valid framework directory: ${watchPath}`, }; } return { valid: true }; }