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: ESM support #759

Merged
merged 49 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
bb489fb
fix: set moduleResolution to Node16
mdonnalley Jul 26, 2023
add1235
fix: use file urls
mdonnalley Jul 26, 2023
1c810d0
chore: add types
mdonnalley Jul 26, 2023
c697617
feat: support linking CJS plugins into ESM plugins
mdonnalley Jul 28, 2023
1396b41
chore: integration tests for module interoperability
mdonnalley Jul 31, 2023
b09e6d0
Merge branch 'main' into mdonnalley/esm-support
mdonnalley Aug 3, 2023
4e76304
test: add CJS/ESM interoperability tests
mdonnalley Aug 8, 2023
956015e
fix: e2e tests
mdonnalley Aug 8, 2023
e8c4b2f
test: isolate cache, config, and data dir for e2e tests
mdonnalley Aug 8, 2023
63f34c5
test: test bug
mdonnalley Aug 9, 2023
0f45299
test: make less noisy
mdonnalley Aug 9, 2023
e0d69bb
test: esm/cjs hooks
mdonnalley Aug 9, 2023
1ae82f2
chore: parallelize e2e tests
mdonnalley Aug 9, 2023
c84e1b6
chore: typo
mdonnalley Aug 9, 2023
fec3c69
test: parallelize e2e tests
mdonnalley Aug 9, 2023
8e5157c
test: no more hanging tests
mdonnalley Aug 10, 2023
775c28a
chore: un-parallelize esm-cjs tests
mdonnalley Aug 10, 2023
0b8e332
chore: add DEBUG to esm-cjs interop tests
mdonnalley Aug 10, 2023
96800d4
chore: add DEBUG to esm-cjs interop tests
mdonnalley Aug 10, 2023
1be16fc
chore: try enabling debug again
mdonnalley Aug 10, 2023
83b9911
test: fix node 20 tests
mdonnalley Aug 10, 2023
47f204b
chore: update DEBUG env var
mdonnalley Aug 10, 2023
8c18164
chore: debug tests
mdonnalley Aug 10, 2023
36ae625
chore: debug tests
mdonnalley Aug 10, 2023
b1ba804
test: more debugging
mdonnalley Aug 11, 2023
d5e9b7b
test: more debugging
mdonnalley Aug 11, 2023
cfa9053
test: more debugging
mdonnalley Aug 11, 2023
6096973
test: more debugging
mdonnalley Aug 11, 2023
24c6ca1
test: more debugging
mdonnalley Aug 11, 2023
ef19b8f
test: more debugging
mdonnalley Aug 11, 2023
0e2398d
test: more debugging
mdonnalley Aug 11, 2023
83fe84f
test: use path.join
mdonnalley Aug 11, 2023
f238a94
test: stop using replace-in-file
mdonnalley Aug 12, 2023
0a65cf1
chore: more test debugging
mdonnalley Aug 12, 2023
58c664d
fix: use path.join when registering ts-node
mdonnalley Aug 14, 2023
0df9bfa
test: run in parallel
mdonnalley Aug 14, 2023
ea8e0dc
test: run tests serially
mdonnalley Aug 14, 2023
572918f
fix: dont remove undefined values from tsconfig
mdonnalley Aug 14, 2023
bb68d78
fix: further isolate ts-configs
mdonnalley Aug 14, 2023
215a0c4
feat: throw error when loading ESM paths from linked plugins
mdonnalley Aug 15, 2023
c3f7d54
fix: default esm to true
mdonnalley Aug 15, 2023
2d18e45
test: compilation errors
mdonnalley Aug 15, 2023
cf8b0c0
feat: better developer experience
mdonnalley Aug 21, 2023
b01083f
fix: add getPluginsList
mdonnalley Aug 23, 2023
56245c4
chore(release): 2.11.9 [skip ci]
svc-cli-bot Aug 23, 2023
cba7a75
fix: add getPluginsList to Config interface
mdonnalley Aug 23, 2023
0e13bbd
chore(release): 2.11.10 [skip ci]
svc-cli-bot Aug 23, 2023
9625fb8
Merge branch 'main' into mdonnalley/esm-support
mdonnalley Aug 23, 2023
19bbab3
Merge branch 'prerelease/v3' into mdonnalley/esm-support
mdonnalley Aug 31, 2023
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
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ jobs:
- run: yarn install --network-timeout 600000
- run: yarn build
- run: yarn test:e2e
esm-cjs-interop:
needs: linux-unit-tests
strategy:
matrix:
os: ["ubuntu-latest", "windows-latest"]
node_version: [lts/-1, lts/*, latest]
exclude:
- os: windows-latest
node_version: lts/*
- os: windows-latest
node_version: lts/-1
fail-fast: false
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}
cache: yarn
- run: yarn install --network-timeout 600000
- run: yarn build
- run: yarn test:esm-cjs
nuts:
needs: linux-unit-tests
uses: salesforcecli/github-workflows/.github/workflows/externalNut.yml@main
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## [2.11.10](https://github.com/oclif/core/compare/2.11.9...2.11.10) (2023-08-23)


### Bug Fixes

* add getPluginsList to Config interface ([cba7a75](https://github.com/oclif/core/commit/cba7a755cf5d0ff4886edd0db33d911441979984))



## [2.11.9](https://github.com/oclif/core/compare/2.11.8...2.11.9) (2023-08-23)


### Bug Fixes

* add getPluginsList ([b01083f](https://github.com/oclif/core/commit/b01083fb7132f3b3b35b98a6d43996b1713ca5ef))



## [2.11.8](https://github.com/oclif/core/compare/2.11.7...2.11.8) (2023-08-08)


Expand Down
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,14 @@
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"commitlint": "^12.1.4",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"eslint-config-oclif": "^4.0.0",
"eslint-config-oclif-typescript": "^1.0.3",
"fancy-test": "^2.0.16",
"globby": "^11.1.0",
"husky": "6",
"mocha": "^8.4.0",
"mocha": "^10.2.0",
"nock": "^13.3.0",
"proxyquire": "^2.1.3",
"shelljs": "^0.8.5",
Expand Down Expand Up @@ -110,14 +111,15 @@
"scripts": {
"build": "shx rm -rf lib && tsc",
"commitlint": "commitlint",
"compile": "tsc",
"lint": "eslint . --ext .ts --config .eslintrc",
"posttest": "yarn lint",
"compile": "tsc",
"prepack": "yarn run build",
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
"test:e2e": "mocha --forbid-only \"test/**/*.e2e.ts\" --timeout 1200000",
"pretest": "yarn build --noEmit && tsc -p test --noEmit --skipLibCheck",
"test:perf": "ts-node test/perf/parser.perf.ts"
"test:e2e": "mocha --forbid-only \"test/**/*.e2e.ts\" --parallel --timeout 1200000",
"test:esm-cjs": "cross-env DEBUG=e2e:* ts-node test/integration/esm-cjs.ts",
"test:perf": "ts-node test/perf/parser.perf.ts",
"test": "mocha --forbid-only \"test/**/*.test.ts\""
},
"types": "lib/index.d.ts"
}
11 changes: 11 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export class Config implements IConfig {

private _commandIDs!: string[]

private static _rootPlugin: Plugin.Plugin

constructor(public options: Options) {}

static async load(opts: LoadOptions = module.filename || __dirname): Promise<Config> {
Expand All @@ -127,11 +129,16 @@ export class Config implements IConfig {
return config
}

static get rootPlugin(): Plugin.Plugin | undefined {
return Config._rootPlugin
}

// eslint-disable-next-line complexity
public async load(): Promise<void> {
settings.performanceEnabled = (settings.performanceEnabled === undefined ? this.options.enablePerf : settings.performanceEnabled) ?? false
const plugin = new Plugin.Plugin({root: this.options.root})
await plugin.load()
Config._rootPlugin = plugin
this.plugins.push(plugin)
this.root = plugin.root
this.pjson = plugin.pjson
Expand Down Expand Up @@ -544,6 +551,10 @@ export class Config implements IConfig {
return url.toString()
}

public getPluginsList(): IPlugin[] {
return this.plugins
}

protected dir(category: 'cache' | 'data' | 'config'): string {
const base = process.env[`XDG_${category.toUpperCase()}_HOME`] ||
(this.windows && process.env.LOCALAPPDATA) ||
Expand Down
5 changes: 4 additions & 1 deletion src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export class Plugin implements IPlugin {

type!: string

moduleType!: 'module' | 'commonjs'

root!: string

alias!: string
Expand Down Expand Up @@ -150,6 +152,7 @@ export class Plugin implements IPlugin {
this.root = root
this._debug('reading %s plugin %s', this.type, root)
this.pjson = await loadJSON(path.join(root, 'package.json'))
this.moduleType = this.pjson.type === 'module' ? 'module' : 'commonjs'
this.name = this.pjson.name
this.alias = this.options.name ?? this.pjson.name
const pjsonPath = path.join(root, 'package.json')
Expand Down Expand Up @@ -185,7 +188,7 @@ export class Plugin implements IPlugin {
public get commandsDir(): string | undefined {
if (this._commandsDir) return this._commandsDir

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

Expand Down
136 changes: 96 additions & 40 deletions src/config/ts-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,27 @@ import * as fs from 'fs'
import * as path from 'path'
import * as TSNode from 'ts-node'

import {TSConfig} from '../interfaces/ts-config'
import {TSConfig, Plugin} from '../interfaces'
import {settings} from '../settings'
import {isProd} from '../util'
import {Debug} from './util'
import {Config} from './config'
import {memoizedWarn} from '../errors'
// eslint-disable-next-line new-cap
const debug = Debug('ts-node')

const TYPE_ROOTS = [`${__dirname}/../node_modules/@types`]
const ROOT_DIRS: string[] = []
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 = path.join(root, 'tsconfig.json')
let typescript: typeof import('typescript') | undefined
try {
typescript = require('typescript')
} catch {
try {
typescript = require(root + '/node_modules/typescript')
typescript = require(path.join(root, 'node_modules', 'typescript'))
} catch {}
}

Expand All @@ -34,71 +37,124 @@ function loadTSConfig(root: string): TSConfig | undefined {
'did not contain a "compilerOptions" section.')
}

TS_CONFIGS[root] = tsconfig
return tsconfig
}
}

function registerTSNode(root: string) {
function registerTSNode(root: string): TSConfig | undefined {
const tsconfig = 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)
const tsNode: typeof TSNode = require(tsNodePath)

TYPE_ROOTS.push(`${root}/node_modules/@types`)
const typeRoots = [
path.join(root, 'node_modules', '@types'),
]

const rootDirs: string[] = []

if (tsconfig.compilerOptions.rootDirs) {
ROOT_DIRS.push(...tsconfig.compilerOptions.rootDirs.map(r => path.join(root, r)))
for (const r of tsconfig.compilerOptions.rootDirs) {
rootDirs.push(path.join(root, r))
}
} else if (tsconfig.compilerOptions.rootDir) {
rootDirs.push(path.join(root, tsconfig.compilerOptions.rootDir))
} else {
ROOT_DIRS.push(`${root}/src`)
rootDirs.push(path.join(root, 'src'))
}

const cwd = process.cwd()
try {
process.chdir(root)
tsNode.register({
skipProject: true,
transpileOnly: true,
compilerOptions: {
esModuleInterop: tsconfig.compilerOptions.esModuleInterop,
target: tsconfig.compilerOptions.target || 'es2017',
experimentalDecorators: tsconfig.compilerOptions.experimentalDecorators || false,
emitDecoratorMetadata: tsconfig.compilerOptions.emitDecoratorMetadata || false,
module: 'commonjs',
sourceMap: true,
rootDirs: ROOT_DIRS,
typeRoots: TYPE_ROOTS,
jsx: 'react',
},
})
return tsconfig
} finally {
process.chdir(cwd)
const conf: TSNode.RegisterOptions = {
compilerOptions: {
esModuleInterop: tsconfig.compilerOptions.esModuleInterop,
target: tsconfig.compilerOptions.target ?? 'es2019',
experimentalDecorators: tsconfig.compilerOptions.experimentalDecorators ?? false,
emitDecoratorMetadata: tsconfig.compilerOptions.emitDecoratorMetadata ?? false,
module: tsconfig.compilerOptions.module ?? 'commonjs',
sourceMap: tsconfig.compilerOptions.sourceMap ?? true,
rootDirs,
typeRoots,
},
skipProject: true,
transpileOnly: true,
esm: tsconfig['ts-node']?.esm ?? true,
scope: true,
scopeDir: root,
cwd: root,
experimentalSpecifierResolution: tsconfig['ts-node']?.experimentalSpecifierResolution ?? 'explicit',
}

if (tsconfig.compilerOptions.moduleResolution) {
// @ts-expect-error TSNode.RegisterOptions.compilerOptions is typed as a plain object
conf.compilerOptions.moduleResolution = tsconfig.compilerOptions.moduleResolution
}

if (tsconfig.compilerOptions.jsx) {
// @ts-expect-error TSNode.RegisterOptions.compilerOptions is typed as a plain object
conf.compilerOptions.jsx = tsconfig.compilerOptions.jsx
}

tsNode.register(conf)
REGISTERED.add(root)

return tsconfig
}

/**
* Convert a path from the compiled ./lib files to the ./src typescript source
* 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, type?: string): string
export function tsPath(root: string, orig: string | undefined, type?: string): string | undefined
export function tsPath(root: string, orig: string | undefined, type?: string): string | undefined {
export function tsPath(root: string, orig: string, plugin: Plugin): string
export function tsPath(root: string, orig: string | undefined, plugin?: Plugin): string | undefined
// eslint-disable-next-line complexity
export function tsPath(root: string, orig: string | undefined, plugin?: Plugin): string | undefined {
if (!orig) return orig
orig = orig.startsWith(root) ? orig : path.join(root, orig)

const skipTSNode =
// the CLI specifically turned it off
(settings.tsnodeEnabled === false) ||
// the CLI didn't specify ts-node and it is production
(settings.tsnodeEnabled === undefined && isProd())
// NOTE: The order of these checks matter!

// We always want to load the tsconfig for linked plugins.
if (skipTSNode && type !== 'link') return orig
if (settings.tsnodeEnabled === false) {
debug(`Skipping ts-node registration for ${root} because tsNodeEnabled is explicitly set to false`)
return orig
}

// Skip ts-node registration if plugin is an ESM plugin executing from a CJS plugin
if (plugin?.moduleType === 'module' && Config.rootPlugin?.moduleType === 'commonjs') {
debug(`Skipping ts-node registration for ${root} because it's an ESM module but the root plugin is CommonJS`)
if (plugin.type === 'link')
memoizedWarn(`${plugin.name} is a linked ESM module and cannot be auto-compiled from a CommonJS root plugin. Existing compiled source will be used instead.`)

return orig
}

// If plugin is an ESM plugin being executed from an ESM root plugin, check to see if ts-node/esm loader has been set
// either in the NODE_OPTIONS env var or from the exec args. If the ts-node/esm loader has NOT been loaded then we want
// to skip ts-node registration so that it falls back on the compiled source.
if (plugin?.moduleType === 'module') {
const tsNodeEsmLoaderInExecArgv = process.execArgv.includes('--loader') && process.execArgv.includes('ts-node/esm')
const tsNodeEsmLoaderInNodeOptions = process.env.NODE_OPTIONS?.includes('--loader=ts-node/esm') ?? false
if (!tsNodeEsmLoaderInExecArgv && !tsNodeEsmLoaderInNodeOptions) {
debug(`Skipping ts-node registration for ${root} because it's an ESM module but the ts-node/esm loader hasn't been run`)
debug('try setting NODE_OPTIONS="--loader ts-node/esm" in your environment.')
if (plugin.type === 'link') {
memoizedWarn(`${plugin.name} is a linked ESM module and cannot be auto-compiled without setting NODE_OPTIONS="--loader=ts-node/esm" in the environment. Existing compiled source will be used instead.`)
}

return orig
}
}

if (settings.tsnodeEnabled === undefined && isProd() && plugin?.type !== 'link') {
debug(`Skipping ts-node registration for ${root} because NODE_ENV is NOT "test" or "development"`)
return orig
}

try {
const tsconfig = type === 'link' ? registerTSNode(root) : loadTSConfig(root)
const tsconfig = registerTSNode(root)
if (!tsconfig) return orig
const {rootDir, rootDirs, outDir} = tsconfig.compilerOptions
const rootDirPath = rootDir || (rootDirs || [])[0]
Expand Down
2 changes: 2 additions & 0 deletions src/errors/errors/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ export class CLIError extends Error implements OclifError {
oclif: OclifError['oclif'] = {}

code?: string
suggestions?: string[]

constructor(error: string | Error, options: { exit?: number | false } & PrettyPrintableError = {}) {
super(error instanceof Error ? error.message : error)
addOclifExitCode(this, options)
this.code = options.code
this.suggestions = options.suggestions
}

get stack(): string {
Expand Down
7 changes: 7 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,10 @@ export function warn(input: string | Error): void {
console.error(message)
if (config.errorLogger) config.errorLogger.log(err?.stack ?? '')
}

const WARNINGS = new Set<string | Error>()
export function memoizedWarn(input: string | Error): void {
if (!WARNINGS.has(input)) warn(input)

WARNINGS.add(input)
}
1 change: 1 addition & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export interface Config {
s3Url(key: string): string;
s3Key(type: 'versioned' | 'unversioned', ext: '.tar.gz' | '.tar.xz', options?: Config.s3Key.Options): string;
s3Key(type: keyof PJSON.S3.Templates, options?: Config.s3Key.Options): string;
getPluginsList(): Plugin[];
}

export namespace Config {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/pjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface PJSON {
bin?: string;
dirname?: string;
hooks?: Record<string, string | string[]>;
plugins?: string[];
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/interfaces/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export interface Plugin {
* examples: core, link, user, dev
*/
type: string;
/**
* Plugin is written in ESM or CommonJS
*/
moduleType: 'module' | 'commonjs';
/**
* base path of plugin
*/
Expand Down
Loading