Skip to content

Commit

Permalink
fix: use yarn to install deps when linking yarn plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Apr 11, 2024
1 parent e1a6eaa commit a5d1e6c
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 128 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"npm-package-arg": "^11.0.1",
"npm-run-path": "^5.3.0",
"semver": "^7.6.0",
"validate-npm-package-name": "^5.0.0"
"validate-npm-package-name": "^5.0.0",
"yarn": "^1.22.22"
},
"devDependencies": {
"@commitlint/config-conventional": "^18",
Expand Down
92 changes: 92 additions & 0 deletions src/fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {Errors, ux} from '@oclif/core'
import makeDebug from 'debug'
import {fork as cpFork} from 'node:child_process'
import {npmRunPathEnv} from 'npm-run-path'

import {LogLevel} from './log-level.js'

export type ExecOptions = {
cwd: string
logLevel: LogLevel
}

export type Output = {
stderr: string[]
stdout: string[]
}

const debug = makeDebug('@oclif/plugin-plugins:fork')

export async function fork(modulePath: string, args: string[] = [], {cwd, logLevel}: ExecOptions): Promise<Output> {
return new Promise((resolve, reject) => {
const forked = cpFork(modulePath, args, {
cwd,
env: {
...npmRunPathEnv(),
// Disable husky hooks because a plugin might be trying to install them, which will
// break the install since the install location isn't a .git directory.
HUSKY: '0',
},
execArgv: process.execArgv
.join(' ')
// Remove --loader ts-node/esm from execArgv so that the subprocess doesn't fail if it can't find ts-node.
// The ts-node/esm loader isn't need to execute npm or yarn commands anyways.
.replace('--loader ts-node/esm', '')
.replace('--loader=ts-node/esm', '')
.split(' ')
.filter(Boolean),
stdio: [0, null, null, 'ipc'],
})

const possibleLastLinesOfNpmInstall = ['up to date', 'added']
const stderr: string[] = []
const stdout: string[] = []
const loggedStderr: string[] = []
const loggedStdout: string[] = []

const shouldPrint = (str: string): boolean => {
// For ux cleanliness purposes, don't print the final line of npm install output if
// the log level is 'notice' and there's no other output.
const noOtherOutput = loggedStderr.length === 0 && loggedStdout.length === 0
const isLastLine = possibleLastLinesOfNpmInstall.some((line) => str.startsWith(line))
if (noOtherOutput && isLastLine && logLevel === 'notice') {
return false
}

return logLevel !== 'silent'
}

forked.stderr?.setEncoding('utf8')
forked.stderr?.on('data', (d: Buffer) => {
const output = d.toString().trim()
stderr.push(output)
if (shouldPrint(output)) {
loggedStderr.push(output)
ux.log(output)
} else debug(output)
})

forked.stdout?.setEncoding('utf8')
forked.stdout?.on('data', (d: Buffer) => {
const output = d.toString().trim()
stdout.push(output)
if (shouldPrint(output)) {
loggedStdout.push(output)
ux.log(output)
} else debug(output)
})

forked.on('error', reject)
forked.on('exit', (code: number) => {
if (code === 0) {
resolve({stderr, stdout})
} else {
reject(
new Errors.CLIError(`${modulePath} ${args.join(' ')} exited with code ${code}`, {
suggestions: ['Run with DEBUG=@oclif/plugin-plugins* to see debug output.'],
}),
)
}
})
})
}
99 changes: 7 additions & 92 deletions src/npm.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,18 @@
import {Errors, Interfaces, ux} from '@oclif/core'
import {Interfaces, ux} from '@oclif/core'
import makeDebug from 'debug'
import {fork as cpFork} from 'node:child_process'
import {readFile} from 'node:fs/promises'
import {createRequire} from 'node:module'
import {join, sep} from 'node:path'
import {npmRunPathEnv} from 'npm-run-path'

import {ExecOptions, Output, fork} from './fork.js'
import {LogLevel} from './log-level.js'

const debug = makeDebug('@oclif/plugin-plugins:npm')

type ExecOptions = {
cwd: string
logLevel: LogLevel
}

type InstallOptions = ExecOptions & {
prod?: boolean
}

export type NpmOutput = {
stderr: string[]
stdout: string[]
}

async function fork(modulePath: string, args: string[] = [], {cwd, logLevel}: ExecOptions): Promise<NpmOutput> {
return new Promise((resolve, reject) => {
const forked = cpFork(modulePath, args, {
cwd,
env: {
...npmRunPathEnv(),
// Disable husky hooks because a plugin might be trying to install them, which will
// break the install since the install location isn't a .git directory.
HUSKY: '0',
},
execArgv: process.execArgv
.join(' ')
// Remove --loader ts-node/esm from execArgv so that the subprocess doesn't fail if it can't find ts-node.
// The ts-node/esm loader isn't need to execute npm commands anyways.
.replace('--loader ts-node/esm', '')
.replace('--loader=ts-node/esm', '')
.split(' ')
.filter(Boolean),
stdio: [0, null, null, 'ipc'],
})

const possibleLastLinesOfNpmInstall = ['up to date', 'added']
const stderr: string[] = []
const stdout: string[] = []
const loggedStderr: string[] = []
const loggedStdout: string[] = []

const shouldPrint = (str: string): boolean => {
// For ux cleanliness purposes, don't print the final line of npm install output if
// the log level is 'notice' and there's no other output.
const noOtherOutput = loggedStderr.length === 0 && loggedStdout.length === 0
const isLastLine = possibleLastLinesOfNpmInstall.some((line) => str.startsWith(line))
if (noOtherOutput && isLastLine && logLevel === 'notice') {
return false
}

return logLevel !== 'silent'
}

forked.stderr?.setEncoding('utf8')
forked.stderr?.on('data', (d: Buffer) => {
const output = d.toString().trim()
stderr.push(output)
if (shouldPrint(output)) {
loggedStderr.push(output)
ux.log(output)
} else debug(output)
})

forked.stdout?.setEncoding('utf8')
forked.stdout?.on('data', (d: Buffer) => {
const output = d.toString().trim()
stdout.push(output)
if (shouldPrint(output)) {
loggedStdout.push(output)
ux.log(output)
} else debug(output)
})

forked.on('error', reject)
forked.on('exit', (code: number) => {
if (code === 0) {
resolve({stderr, stdout})
} else {
reject(
new Errors.CLIError(`${modulePath} ${args.join(' ')} exited with code ${code}`, {
suggestions: ['Run with DEBUG=@oclif/plugin-plugins* to see debug output.'],
}),
)
}
})
})
}

export class NPM {
private bin: string | undefined
private config: Interfaces.Config
Expand All @@ -108,7 +23,7 @@ export class NPM {
this.logLevel = logLevel
}

async exec(args: string[] = [], options: ExecOptions): Promise<NpmOutput> {
async exec(args: string[] = [], options: ExecOptions): Promise<Output> {
const bin = await this.findNpm()
debug('npm binary path', bin)

Expand All @@ -130,20 +45,20 @@ export class NPM {
}
}

async install(args: string[], opts: InstallOptions): Promise<NpmOutput> {
async install(args: string[], opts: InstallOptions): Promise<Output> {
const prod = opts.prod ? ['--omit', 'dev'] : []
return this.exec(['install', ...args, ...prod, '--no-audit'], opts)
}

async uninstall(args: string[], opts: ExecOptions): Promise<NpmOutput> {
async uninstall(args: string[], opts: ExecOptions): Promise<Output> {
return this.exec(['uninstall', ...args], opts)
}

async update(args: string[], opts: ExecOptions): Promise<NpmOutput> {
async update(args: string[], opts: ExecOptions): Promise<Output> {
return this.exec(['update', ...args], opts)
}

async view(args: string[], opts: ExecOptions): Promise<NpmOutput> {
async view(args: string[], opts: ExecOptions): Promise<Output> {
return this.exec(['view', ...args], {...opts, logLevel: 'silent'})
}

Expand Down
35 changes: 28 additions & 7 deletions src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {basename, dirname, join, resolve} from 'node:path'
import {fileURLToPath} from 'node:url'
import {gt, valid, validRange} from 'semver'

import {Output} from './fork.js'
import {LogLevel} from './log-level.js'
import {NPM, NpmOutput} from './npm.js'
import {NPM} from './npm.js'
import {uniqWith} from './util.js'
import {Yarn} from './yarn.js'

type UserPJSON = {
dependencies: Record<string, string>
Expand Down Expand Up @@ -58,7 +60,7 @@ function extractIssuesLocation(
}
}

function notifyUser(plugin: Config, output: NpmOutput): void {
function notifyUser(plugin: Config, output: Output): void {
const containsWarnings = [...output.stdout, ...output.stderr].some((l) => l.includes('npm WARN'))
if (containsWarnings) {
ux.logToStderr(chalk.bold.yellow(`\nThese warnings can only be addressed by the owner(s) of ${plugin.name}.`))
Expand Down Expand Up @@ -231,11 +233,30 @@ export default class Plugins {
this.isValidPlugin(c)

if (install) {
await this.npm.install([], {
cwd: c.root,
logLevel: this.logLevel,
prod: false,
})
if (await fileExists(join(c.root, 'yarn.lock'))) {
this.debug('installing dependencies with yarn')
const yarn = new Yarn({config: this.config, logLevel: this.logLevel})
await yarn.install([], {
cwd: c.root,
logLevel: this.logLevel,
prod: false,
})
} else if (await fileExists(join(c.root, 'package-lock.json'))) {
this.debug('installing dependencies with npm')
await this.npm.install([], {
cwd: c.root,
logLevel: this.logLevel,
prod: false,
})
} else if (await fileExists(join(c.root, 'pnpm-lock.yaml'))) {
ux.warn(
`pnpm is not supported for installing after a link. The link succeeded, but you may need to run 'pnpm install' in ${c.root}.`,
)
} else {
ux.warn(
`No lockfile found in ${c.root}. The link succeeded, but you may need to install the dependencies in your project.`,
)
}
}

await this.add({name: c.name, root: c.root, type: 'link'})
Expand Down
Loading

0 comments on commit a5d1e6c

Please sign in to comment.