Skip to content

Commit

Permalink
feat: read extended tsconfigs (#845)
Browse files Browse the repository at this point in the history
* feat: add swc option if possible

* chore: better debugs

* feat: use tsconfck for resolving extended tsconfigs

* test: plugins e2e

* feat: improve implementation

* test: remove tsPathSync test

* feat: pass entire tsconfig to ts-node

* fix: handle invalid json

* chore: clean up

* fix: return tsconfig for previously registered

* test: code coverage

* test: handle line breaks

* test: handle line breaks

* test: use different text for warning assertion

* chore: code review

* chore: code review

* fix: find-root debug logs

* fix: merge ts-node opts in reverse
  • Loading branch information
mdonnalley authored Nov 2, 2023
1 parent bcfc372 commit 59145ee
Show file tree
Hide file tree
Showing 17 changed files with 217 additions and 120 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"strip-ansi": "^6.0.1",
"supports-color": "^8.1.1",
"supports-hyperlinks": "^2.2.0",
"tsconfck": "^3.0.0",
"widest-line": "^3.1.0",
"wordwrap": "^1.0.0",
"wrap-ansi": "^7.0.0"
Expand All @@ -43,6 +44,7 @@
"@types/chai-as-promised": "^7.1.5",
"@types/clean-stack": "^2.1.1",
"@types/cli-progress": "^3.11.0",
"@types/debug": "^4.1.10",
"@types/ejs": "^3.1.3",
"@types/indent-string": "^4.0.1",
"@types/js-yaml": "^3.12.7",
Expand Down
87 changes: 51 additions & 36 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {sync} from 'globby'
import globby from 'globby'
import {join, parse, relative, sep} from 'node:path'
import {inspect} from 'node:util'

Expand All @@ -13,7 +13,7 @@ import {OCLIF_MARKER_OWNER, Performance} from '../performance'
import {cacheCommand} from '../util/cache-command'
import {findRoot} from '../util/find-root'
import {readJson, requireJson} from '../util/fs'
import {castArray, compact, isProd, mapValues} from '../util/util'
import {castArray, compact, isProd} from '../util/util'
import {tsPath} from './ts-node'
import {Debug, getCommandIdPermutations} from './util'

Expand Down Expand Up @@ -41,15 +41,34 @@ const search = (cmd: any) => {
return Object.values(cmd).find((cmd: any) => typeof cmd.run === 'function')
}

const GLOB_PATTERNS = [
'**/*.+(js|cjs|mjs|ts|tsx|mts|cts)',
'!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js|d.mts|d.cts)?(x)',
]

function processCommandIds(files: string[]): string[] {
return files.map((file) => {
const p = parse(file)
const topics = p.dir.split('/')
const command = p.name !== 'index' && p.name
const id = [...topics, command].filter(Boolean).join(':')
return id === '' ? '.' : id
})
}

export class Plugin implements IPlugin {
alias!: string

alreadyLoaded = false

children: Plugin[] = []

commandIDs: string[] = []

commands!: Command.Loadable[]

commandsDir: string | undefined

hasManifest = false

hooks!: {[k: string]: string[]}
Expand Down Expand Up @@ -80,44 +99,13 @@ export class Plugin implements IPlugin {

_base = `${_pjson.name}@${_pjson.version}`

private _commandsDir!: string | undefined

// eslint-disable-next-line new-cap
protected _debug = Debug()

private flexibleTaxonomy!: boolean

constructor(public options: PluginOptions) {}

public get commandIDs(): string[] {
if (!this.commandsDir) return []

const marker = Performance.mark(OCLIF_MARKER_OWNER, `plugin.commandIDs#${this.name}`, {plugin: this.name})
this._debug(`loading IDs from ${this.commandsDir}`)
const patterns = [
'**/*.+(js|cjs|mjs|ts|tsx|mts|cts)',
'!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js|d.mts|d.cts)?(x)',
]
const ids = sync(patterns, {cwd: this.commandsDir}).map((file) => {
const p = parse(file)
const topics = p.dir.split('/')
const command = p.name !== 'index' && p.name
const id = [...topics, command].filter(Boolean).join(':')
return id === '' ? '.' : id
})
this._debug('found commands', ids)
marker?.addDetails({count: ids.length})
marker?.stop()
return ids
}

public get commandsDir(): string | undefined {
if (this._commandsDir) return this._commandsDir

this._commandsDir = tsPath(this.root, this.pjson.oclif.commands, this)
return this._commandsDir
}

public get topics(): Topic[] {
return topicsToArray(this.pjson.oclif.topics || {})
}
Expand Down Expand Up @@ -163,7 +151,7 @@ export class Plugin implements IPlugin {
}

public async load(): Promise<void> {
this.type = this.options.type || 'core'
this.type = this.options.type ?? 'core'
this.tag = this.options.tag
this.isRoot = this.options.isRoot ?? false
if (this.options.parent) this.parent = this.options.parent as Plugin
Expand All @@ -174,7 +162,7 @@ export class Plugin implements IPlugin {
this.type === 'link' && !this.parent ? this.options.root : await findRoot(this.options.name, this.options.root)
if (!root) throw new CLIError(`could not find package.json with ${inspect(this.options)}`)
this.root = root
this._debug('reading %s plugin %s', this.type, root)
this._debug(`loading ${this.type} plugin from ${root}`)
this.pjson = await readJson(join(root, 'package.json'))
this.flexibleTaxonomy = this.options?.flexibleTaxonomy || this.pjson.oclif?.flexibleTaxonomy || false
this.moduleType = this.pjson.type === 'module' ? 'module' : 'commonjs'
Expand All @@ -192,7 +180,17 @@ export class Plugin implements IPlugin {
this.pjson.oclif = this.pjson['cli-engine'] || {}
}

this.hooks = mapValues(this.pjson.oclif.hooks ?? {}, (i) => castArray(i).map((i) => tsPath(this.root, i, this)))
this.commandsDir = await this.getCommandsDir()
this.commandIDs = await this.getCommandIDs()

this.hooks = Object.fromEntries(
await Promise.all(
Object.entries(this.pjson.oclif.hooks ?? {}).map(async ([k, v]) => [
k,
await Promise.all(castArray(v).map(async (i) => tsPath(this.root, i, this))),
]),
),
)

this.manifest = await this._manifest()
this.commands = Object.entries(this.manifest.commands)
Expand Down Expand Up @@ -292,6 +290,23 @@ export class Plugin implements IPlugin {
return err
}

private async getCommandIDs(): Promise<string[]> {
if (!this.commandsDir) return []

const marker = Performance.mark(OCLIF_MARKER_OWNER, `plugin.getCommandIDs#${this.name}`, {plugin: this.name})
this._debug(`loading IDs from ${this.commandsDir}`)
const files = await globby(GLOB_PATTERNS, {cwd: this.commandsDir})
const ids = processCommandIds(files)
this._debug('found commands', ids)
marker?.addDetails({count: ids.length})
marker?.stop()
return ids
}

private async getCommandsDir(): Promise<string | undefined> {
return tsPath(this.root, this.pjson.oclif.commands, this)
}

private warn(err: CLIError | Error | string, scope?: string): void {
if (this.warned) return
if (typeof err === 'string') err = new Error(err)
Expand Down
88 changes: 44 additions & 44 deletions src/config/ts-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as TSNode from 'ts-node'
import {memoizedWarn} from '../errors'
import {Plugin, TSConfig} from '../interfaces'
import {settings} from '../settings'
import {existsSync, readJsonSync} from '../util/fs'
import {existsSync, readJson} from '../util/fs'
import {isProd} from '../util/util'
import Cache from './cache'
import {Debug} from './util'
Expand All @@ -15,42 +15,40 @@ const debug = Debug('ts-node')
export const TS_CONFIGS: Record<string, TSConfig> = {}
const REGISTERED = new Set<string>()

function loadTSConfig(root: string): TSConfig | undefined {
if (TS_CONFIGS[root]) return TS_CONFIGS[root]
const tsconfigPath = join(root, 'tsconfig.json')
let typescript: typeof import('typescript') | undefined
async function loadTSConfig(root: string): Promise<TSConfig | undefined> {
try {
typescript = require('typescript')
} catch {
try {
typescript = require(require.resolve('typescript', {paths: [root, __dirname]}))
} catch {
debug(`Could not find typescript dependency. Skipping ts-node registration for ${root}.`)
memoizedWarn(
'Could not find typescript. Please ensure that typescript is a devDependency. Falling back to compiled source.',
)
return
}
}
if (TS_CONFIGS[root]) return TS_CONFIGS[root]
const tsconfigPath = join(root, 'tsconfig.json')
const tsconfig = await readJson<TSConfig>(tsconfigPath)

if (!tsconfig || Object.keys(tsconfig.compilerOptions).length === 0) return

TS_CONFIGS[root] = tsconfig

if (existsSync(tsconfigPath) && typescript) {
const tsconfig = typescript.parseConfigFileTextToJson(tsconfigPath, readJsonSync(tsconfigPath, false)).config
if (!tsconfig || !tsconfig.compilerOptions) {
throw new Error(
`Could not read and parse tsconfig.json at ${tsconfigPath}, or it ` +
'did not contain a "compilerOptions" section.',
if (tsconfig.extends) {
const {parse} = await import('tsconfck')
const result = await parse(tsconfigPath)
const tsNodeOpts = Object.fromEntries(
(result.extended ?? []).flatMap((e) => Object.entries(e.tsconfig['ts-node'] ?? {})).reverse(),
)

TS_CONFIGS[root] = {...result.tsconfig, 'ts-node': tsNodeOpts}
}

TS_CONFIGS[root] = tsconfig
return tsconfig
return TS_CONFIGS[root]
} catch (error) {
if (error instanceof SyntaxError) {
debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`)
memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`)
}
}
}

function registerTSNode(root: string): TSConfig | undefined {
const tsconfig = loadTSConfig(root)
async function registerTSNode(root: string): Promise<TSConfig | undefined> {
const tsconfig = await loadTSConfig(root)
if (!tsconfig) return
if (REGISTERED.has(root)) return tsconfig

debug('registering ts-node at', root)
const tsNodePath = require.resolve('ts-node', {paths: [root, __dirname]})
debug('ts-node path:', tsNodePath)
Expand All @@ -76,25 +74,24 @@ function registerTSNode(root: string): TSConfig | undefined {
}
} else if (tsconfig.compilerOptions.rootDir) {
rootDirs.push(join(root, tsconfig.compilerOptions.rootDir))
} else if (tsconfig.compilerOptions.baseUrl) {
rootDirs.push(join(root, tsconfig.compilerOptions.baseUrl))
} else {
rootDirs.push(join(root, 'src'))
}

// Because we need to provide a modified `rootDirs` to ts-node, we need to
// remove `baseUrl` and `rootDir` from `compilerOptions` so that they
// don't conflict.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {baseUrl, rootDir, ...rest} = tsconfig.compilerOptions
const conf: TSNode.RegisterOptions = {
compilerOptions: {
emitDecoratorMetadata: tsconfig.compilerOptions.emitDecoratorMetadata ?? false,
esModuleInterop: tsconfig.compilerOptions.esModuleInterop,
experimentalDecorators: tsconfig.compilerOptions.experimentalDecorators ?? false,
module: tsconfig.compilerOptions.module ?? 'commonjs',
...rest,
rootDirs,
sourceMap: tsconfig.compilerOptions.sourceMap ?? true,
target: tsconfig.compilerOptions.target ?? 'es2019',
typeRoots,
...(tsconfig.compilerOptions.moduleResolution
? {moduleResolution: tsconfig.compilerOptions.moduleResolution}
: {}),
...(tsconfig.compilerOptions.jsx ? {jsx: tsconfig.compilerOptions.jsx} : {}),
},
...tsconfig['ts-node'],
cwd: root,
esm: tsconfig['ts-node']?.esm ?? true,
experimentalSpecifierResolution: tsconfig['ts-node']?.experimentalSpecifierResolution ?? 'explicit',
Expand All @@ -106,7 +103,9 @@ function registerTSNode(root: string): TSConfig | undefined {

tsNode.register(conf)
REGISTERED.add(root)
debug('%O', tsconfig)
debug('tsconfig: %O', tsconfig)
debug('ts-node options: %O', conf)

return tsconfig
}

Expand Down Expand Up @@ -150,9 +149,10 @@ function cannotUseTsNode(root: string, plugin: Plugin | undefined, isProduction:
/**
* Determine the path to the source file from the compiled ./lib files
*/
function determinePath(root: string, orig: string): string {
const tsconfig = registerTSNode(root)
async function determinePath(root: string, orig: string): Promise<string> {
const tsconfig = await registerTSNode(root)
if (!tsconfig) return orig

debug(`determining path for ${orig}`)
const {baseUrl, outDir, rootDir, rootDirs} = tsconfig.compilerOptions
const rootDirPath = rootDir ?? (rootDirs ?? [])[0] ?? baseUrl
Expand Down Expand Up @@ -197,9 +197,9 @@ function determinePath(root: string, orig: string): string {
* this is for developing typescript plugins/CLIs
* if there is a tsconfig and the original sources exist, it attempts to require ts-node
*/
export function tsPath(root: string, orig: string, plugin: Plugin): string
export function tsPath(root: string, orig: string | undefined, plugin?: Plugin): string | undefined
export function tsPath(root: string, orig: string | undefined, plugin?: Plugin): string | undefined {
export async function tsPath(root: string, orig: string, plugin: Plugin): Promise<string>
export async function tsPath(root: string, orig: string | undefined, plugin?: Plugin): Promise<string | undefined>
export async function tsPath(root: string, orig: string | undefined, plugin?: Plugin): Promise<string | undefined> {
const rootPlugin = plugin?.options.isRoot ? plugin : Cache.getInstance().get('rootPlugin')

if (!orig) return orig
Expand Down Expand Up @@ -245,7 +245,7 @@ export function tsPath(root: string, orig: string | undefined, plugin?: Plugin):
}

try {
return determinePath(root, orig)
return await determinePath(root, orig)
} catch (error: any) {
debug(error)
return orig
Expand Down
5 changes: 3 additions & 2 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import write from '../cli-ux/write'
import {OclifError, PrettyPrintableError} from '../interfaces'
import {config} from './config'
import {CLIError, addOclifExitCode} from './errors/cli'
Expand Down Expand Up @@ -32,7 +33,7 @@ export function error(input: Error | string, options: {exit?: false | number} &

if (options.exit === false) {
const message = prettyPrint(err)
console.error(message)
if (message) write.stderr(message + '\n')
if (config.errorLogger) config.errorLogger.log(err?.stack ?? '')
} else throw err
}
Expand All @@ -49,7 +50,7 @@ export function warn(input: Error | string): void {
}

const message = prettyPrint(err)
console.error(message)
if (message) write.stderr(message + '\n')
if (config.errorLogger) config.errorLogger.log(err?.stack ?? '')
}

Expand Down
1 change: 1 addition & 0 deletions src/interfaces/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface Plugin {
alias: string
readonly commandIDs: string[]
commands: Command.Loadable[]
readonly commandsDir: string | undefined
findCommand(id: string, opts: {must: true}): Promise<Command.Class>
findCommand(id: string, opts?: {must: boolean}): Promise<Command.Class> | undefined
readonly hasManifest: boolean
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces/ts-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export interface TSConfig {
sourceMap?: boolean
target?: string
}
extends?: string
'ts-node'?: {
esm?: boolean
experimentalSpecifierResolution?: 'explicit' | 'node'
scope?: boolean
swc?: boolean
}
}
Loading

0 comments on commit 59145ee

Please sign in to comment.