diff --git a/README.md b/README.md index fbab95d0..f58dd8ae 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ COMMANDS preview Launches Nitro server for local testing after `nuxi build`. start Launches Nitro server for local testing after `nuxi build`. test Run tests - typecheck Runs `vue-tsc` to check types throughout your app. + typecheck Runs type-checking throughout your app using `vue-tsc` or Golar. upgrade Upgrade Nuxt complete Generate shell completion scripts diff --git a/packages/nuxi/src/commands/typecheck.ts b/packages/nuxi/src/commands/typecheck.ts index 4e8425e4..b8cb7744 100644 --- a/packages/nuxi/src/commands/typecheck.ts +++ b/packages/nuxi/src/commands/typecheck.ts @@ -1,40 +1,76 @@ +import { existsSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { delimiter } from 'node:path' import process from 'node:process' -import { cancel, confirm, isCancel, spinner } from '@clack/prompts' +import { cancel, confirm, isCancel, select, spinner } from '@clack/prompts' import { defineCommand } from 'citty' import { colors } from 'consola/utils' import { resolveModulePath } from 'exsolve' import { addDevDependency, detectPackageManager } from 'nypm' import { resolve } from 'pathe' -import { readTSConfig } from 'pkg-types' +import { readPackageJSON, readTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' +import { withNodePath } from '../utils/paths' import { cwdArgs, dotEnvArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared' -const REQUIRED_DEPS = { - 'typescript': 'typescript', - 'vue-tsc': 'vue-tsc/bin/vue-tsc.js', -} as const +type TypeChecker = 'vue-tsc' | 'golar' -type DepName = keyof typeof REQUIRED_DEPS +interface TypeCheckerSetup { + checker: TypeChecker + bin: string +} -function resolveDeps({ cache }: { cache?: boolean } = {}) { - const out = {} as Record - // trick to type `name` as `DepName` instead of `string` - let name: DepName - for (name in REQUIRED_DEPS) { - out[name] = resolveModulePath(REQUIRED_DEPS[name], { try: true, cache }) - } - return out +interface TypeCheckerMeta { + label: string + hint: string + packages: readonly string[] + docs?: string +} + +interface ResolvedTypeChecker { + missing: string[] + bin?: string } +const TYPE_CHECKERS: Record = { + 'vue-tsc': { + label: 'vue-tsc', + hint: 'Vue\'s official TypeScript checker', + packages: ['typescript', 'vue-tsc'], + }, + 'golar': { + label: 'Golar', + hint: 'Native-speed type-checking powered by typescript-go', + packages: ['golar', '@golar/vue'], + docs: 'https://golar.dev/languages/vue/', + }, +} + +const CHECKER_PRIORITY: TypeChecker[] = ['vue-tsc', 'golar'] + +const GOLAR_CONFIG_FILES = [ + 'golar.config.ts', + 'golar.config.mts', + 'golar.config.mjs', + 'golar.config.cts', + 'golar.config.cjs', +] as const + +const GOLAR_CONFIG_TEMPLATE = `import { defineConfig } from 'golar/unstable' +import '@golar/vue' + +export default defineConfig({}) +` + export default defineCommand({ meta: { name: 'typecheck', - description: 'Runs `vue-tsc` to check types throughout your app.', + description: 'Runs type-checking throughout your app using `vue-tsc` or Golar.', }, args: { ...cwdArgs, @@ -42,28 +78,39 @@ export default defineCommand({ ...dotEnvArgs, ...extendsArgs, ...legacyRootDirArgs, + checker: { + type: 'string', + description: 'Type checker to use (`vue-tsc` or `golar`)', + }, }, async run(ctx) { process.env.NODE_ENV = process.env.NODE_ENV || 'production' const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - const [supportsProjects, vueTsc] = await Promise.all([ - readTSConfig(cwd).then(r => !!(r.references?.length)), - ensureVueTsc(cwd, resolveDeps()), + const checkerArg = ctx.args.checker + if (checkerArg && !Object.hasOwn(TYPE_CHECKERS, checkerArg)) { + logger.error(`Unknown type checker ${colors.cyan(checkerArg)}. Expected one of: ${CHECKER_PRIORITY.join(', ')}.`) + process.exitCode = 1 + return + } + + const [supportsProjects, typechecker] = await Promise.all([ + readTSConfig(cwd).then(config => !!(config.references?.length)), + resolveTypeChecker(cwd, checkerArg as TypeChecker | undefined), writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { ...ctx.data?.overrides, ...(ctx.args.extends && { extends: ctx.args.extends }), }), ]) - if (!vueTsc) { + if (!typechecker) { process.exitCode = 1 return } const start = Date.now() - const result = await x(vueTsc, supportsProjects ? ['-b', '--noEmit'] : ['--noEmit'], { + const result = await x(typechecker.bin, getTypecheckArgs(typechecker.checker, supportsProjects), { nodeOptions: { stdio: 'inherit', cwd }, }) const duration = `${Date.now() - start}ms` @@ -82,27 +129,191 @@ export default defineCommand({ }, }) -async function ensureVueTsc(cwd: string, deps: Record): Promise { - const missing = (Object.keys(REQUIRED_DEPS) as DepName[]).filter(name => !deps[name]) - if (missing.length === 0) { - return deps['vue-tsc'] +function getTypecheckArgs(checker: TypeChecker, supportsProjects: boolean) { + if (checker === 'golar') { + return supportsProjects + ? ['tsc', '--build', '--noEmit'] + : ['tsc', '--noEmit'] } + return supportsProjects + ? ['-b', '--noEmit'] + : ['--noEmit'] +} + +async function resolveTypeChecker(cwd: string, preferred?: TypeChecker): Promise { + const priority = preferred + ? [preferred] + : hasGolarConfig(cwd) + ? (['golar', 'vue-tsc'] as TypeChecker[]) + : CHECKER_PRIORITY + + for (const checker of priority) { + const resolved = resolveChecker(checker, cwd) + if (resolved.missing.length === 0 && resolved.bin) { + if (checker === 'golar') { + await ensureGolarConfig(cwd) + } + return { checker, bin: resolved.bin } + } + } + + return await promptTypeCheckerInstall(cwd, preferred) +} + +function resolveChecker(checker: TypeChecker, cwd: string, { cache = true } = {}): ResolvedTypeChecker { + const from = withNodePath(cwd) + + if (checker === 'golar') { + const bin = resolveGolarBin(cwd) + const vuePlugin = resolveModulePath('@golar/vue', { from, try: true, cache }) + const missing = [ + ...(!bin ? ['golar'] : []), + ...(!vuePlugin ? ['@golar/vue'] : []), + ] + return { missing, bin } + } + + const typescript = resolveModulePath('typescript', { from, try: true, cache }) + const bin = resolveModulePath('vue-tsc/bin/vue-tsc.js', { from, try: true, cache }) + const missing = [ + ...(!typescript ? ['typescript'] : []), + ...(!bin ? ['vue-tsc'] : []), + ] + return { missing, bin } +} + +function resolveGolarBin(cwd: string) { + // golar restricts package exports, so resolve the CLI entry directly + let dir = cwd + while (true) { + const candidate = resolve(dir, 'node_modules/golar/dist/bin.js') + if (existsSync(candidate)) { + return candidate + } + const parent = resolve(dir, '..') + if (parent === dir) { + break + } + dir = parent + } + + for (const nodePath of process.env.NODE_PATH?.split(delimiter) || []) { + const candidate = resolve(nodePath, 'golar/dist/bin.js') + if (existsSync(candidate)) { + return candidate + } + } + + return undefined +} + +function hasGolarConfig(cwd: string) { + return GOLAR_CONFIG_FILES.some(file => existsSync(resolve(cwd, file))) +} + +async function ensureGolarConfig(cwd: string) { + if (hasGolarConfig(cwd)) { + return + } + + const pkg = await readPackageJSON(cwd).catch(() => null) + const filename = pkg?.type === 'module' ? 'golar.config.ts' : 'golar.config.mts' + await writeFile(resolve(cwd, filename), GOLAR_CONFIG_TEMPLATE) + logger.info(`Created ${colors.cyan(filename)}`) +} + +async function promptTypeCheckerInstall(cwd: string, preferred?: TypeChecker): Promise { const packageManager = await detectPackageManager(cwd, { includeParentDirs: true }) const pmName = packageManager?.name ?? 'npm' - const installCommand = `${packageManager?.command ?? pmName} add ${pmName === 'bun' ? '-d' : '-D'} ${missing.join(' ')}` - - const list = missing.map(name => colors.cyan(name)).join(' and ') - const plural = missing.length > 1 - const are = plural ? 'are' : 'is' - const devDependency = plural ? 'devDependencies' : 'a devDependency' + const devFlag = pmName === 'bun' ? '-d' : '-D' + const pmCommand = packageManager?.command ?? pmName if (!hasTTY) { - logger.error(`${list} ${are} required for ${colors.cyan('nuxt typecheck')}. Install ${plural ? 'them' : 'it'} as ${devDependency}:\n\n ${colors.bold(installCommand)}\n`) + printInstallInstructions(pmCommand, devFlag, preferred ? [preferred] : CHECKER_PRIORITY) + return + } + + let selected = preferred + if (!selected) { + logger.warn(`No type checker found for ${colors.cyan('nuxt typecheck')}.`) + + const answer = await select({ + message: 'Which type checker would you like to use?', + options: CHECKER_PRIORITY.map(name => ({ + value: name, + label: TYPE_CHECKERS[name].label, + hint: TYPE_CHECKERS[name].hint, + })), + initialValue: 'vue-tsc', + }) + + if (isCancel(answer)) { + cancel('Skipping installation.') + return + } + selected = answer + } + + const installCommand = formatInstallCommand(selected, pmCommand, devFlag) + const { missing } = resolveChecker(selected, cwd) + + if (missing.length > 0) { + const installed = await installMissingPackages({ + cwd, + packageManager, + pmName, + packages: missing, + installCommand, + }) + if (!installed) { + return + } + } + + const resolved = resolveChecker(selected, cwd, { cache: false }) + if (!resolved.bin) { + logger.error(`Failed to resolve ${colors.cyan(selected)} after installation. Please check your installation.`) return } - logger.warn(`${list} ${are} required for ${colors.cyan('nuxt typecheck')} but ${plural ? 'were' : 'was'} not found.`) + if (selected === 'golar') { + await ensureGolarConfig(cwd) + } + + return { checker: selected, bin: resolved.bin } +} + +function printInstallInstructions(pmCommand: string, devFlag: string, checkers: readonly TypeChecker[]) { + logger.error(`A type checker is required for ${colors.cyan('nuxt typecheck')}. Install ${checkers.length > 1 ? 'one of the following' : 'it as a devDependency'}:\n`) + + for (const checker of checkers) { + const meta = TYPE_CHECKERS[checker] + const command = formatInstallCommand(checker, pmCommand, devFlag) + const docs = meta.docs ? ` (see ${colors.cyan(meta.docs)})` : '' + logger.info(`${colors.cyan(meta.label)}${docs}:\n\n ${colors.bold(command)}\n`) + } + + if (checkers.includes('golar')) { + logger.info(`Golar also requires a ${colors.cyan('golar.config.ts')} file. One will be created automatically the first time Golar is used.\n`) + } +} + +function formatInstallCommand(checker: TypeChecker, pmCommand: string, devFlag: string) { + return `${pmCommand} add ${devFlag} ${TYPE_CHECKERS[checker].packages.join(' ')}` +} + +async function installMissingPackages(options: { + cwd: string + packageManager: Awaited> + pmName: string + packages: string[] + installCommand: string +}): Promise { + const { cwd, packageManager, pmName, packages, installCommand } = options + const list = packages.map(name => colors.cyan(name)).join(' and ') + const plural = packages.length > 1 + const devDependency = plural ? 'devDependencies' : 'a devDependency' const shouldInstall = await confirm({ message: `Install ${list} as ${devDependency}?`, @@ -111,23 +322,22 @@ async function ensureVueTsc(cwd: string, deps: Record) { @@ -142,7 +352,6 @@ async function writeTypes(cwd: string, dotenv?: string, logLevel?: 'silent' | 'i }, }) - // Generate types and build Nuxt instance await writeTypes(nuxt) await buildNuxt(nuxt) await nuxt.close() diff --git a/packages/nuxi/src/utils/paths.ts b/packages/nuxi/src/utils/paths.ts index 76af1f82..12262d51 100644 --- a/packages/nuxi/src/utils/paths.ts +++ b/packages/nuxi/src/utils/paths.ts @@ -1,3 +1,4 @@ +import { delimiter } from 'node:path' import process from 'node:process' import { relative } from 'pathe' @@ -8,5 +9,5 @@ export function relativeToProcess(path: string) { } export function withNodePath(path: string) { - return [path, ...(process.env.NODE_PATH?.split(':') || [])] + return [path, ...(process.env.NODE_PATH?.split(delimiter) || [])] }