Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: read extended tsconfigs #845

Merged
merged 18 commits into from
Nov 2, 2023
Merged
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: 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 @@ -34,7 +35,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 @@ -51,7 +52,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
Loading