Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
287 changes: 248 additions & 39 deletions packages/nuxi/src/commands/typecheck.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,116 @@
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<keyof typeof REQUIRED_DEPS, string | undefined>
// 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<TypeChecker, TypeCheckerMeta> = {
'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,
...logLevelArgs,
...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`
Expand All @@ -82,27 +129,191 @@ export default defineCommand({
},
})

async function ensureVueTsc(cwd: string, deps: Record<DepName, string | undefined>): Promise<string | undefined> {
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<TypeCheckerSetup | undefined> {
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<TypeCheckerSetup | undefined> {
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<TypeChecker>({
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<ReturnType<typeof detectPackageManager>>
pmName: string
packages: string[]
installCommand: string
}): Promise<boolean> {
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}?`,
Expand All @@ -111,23 +322,22 @@ async function ensureVueTsc(cwd: string, deps: Record<DepName, string | undefine

if (isCancel(shouldInstall) || !shouldInstall) {
cancel(`Skipping installation. Run ${colors.bold(installCommand)} to install manually.`)
return
return false
}

const spin = spinner()
spin.start(`Installing ${list} with ${colors.cyan(pmName)}`)
try {
await addDevDependency(missing, { cwd, packageManager, silent: true })
await addDevDependency(packages, { cwd, packageManager, silent: true })
spin.stop(`Installed ${list}`)
return true
}
catch (error) {
spin.error(`Failed to install ${list}`)
logger.error(error instanceof Error ? error.message : String(error))
logger.info(`You can install ${plural ? 'them' : 'it'} manually with:\n\n ${colors.bold(installCommand)}\n`)
return
return false
}

return resolveDeps({ cache: false })['vue-tsc']
}

async function writeTypes(cwd: string, dotenv?: string, logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record<string, any>) {
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxi/src/utils/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { delimiter } from 'node:path'
import process from 'node:process'
import { relative } from 'pathe'

Expand All @@ -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) || [])]
}
Loading