napi-rs/cli/src/api/build.ts
2024-02-19 18:09:34 +08:00

923 lines
26 KiB
TypeScript

import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir, homedir } from 'node:os'
import { parse, join, resolve } from 'node:path'
import * as colors from 'colorette'
import { include as setjmpInclude, lib as setjmpLib } from 'wasm-sjlj'
import { BuildOptions as RawBuildOptions } from '../def/build.js'
import {
CLI_VERSION,
copyFileAsync,
Crate,
debugFactory,
DEFAULT_TYPE_DEF_HEADER,
fileExists,
getSystemDefaultTarget,
getTargetLinker,
mkdirAsync,
NapiConfig,
parseMetadata,
parseTriple,
processTypeDef,
readFileAsync,
readNapiConfig,
Target,
targetToEnvVar,
tryInstallCargoBinary,
unlinkAsync,
writeFileAsync,
} from '../utils/index.js'
import { createCjsBinding } from './templates/index.js'
import {
createWasiBinding,
createWasiBrowserBinding,
} from './templates/load-wasi-template.js'
import {
WASI_WORKER_BROWSER_TEMPLATE,
WASI_WORKER_TEMPLATE,
} from './templates/wasi-worker-template.js'
const debug = debugFactory('build')
const require = createRequire(import.meta.url)
type OutputKind = 'js' | 'dts' | 'node' | 'exe' | 'wasm'
type Output = {
kind: OutputKind
path: string
}
type BuildOptions = RawBuildOptions & {
cargoOptions?: string[]
}
export async function buildProject(options: BuildOptions) {
debug('napi build command receive options: %O', options)
const cwd = options.cwd ?? process.cwd()
const resolvePath = (...paths: string[]) => resolve(cwd, ...paths)
const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml')
const metadata = parseMetadata(manifestPath)
const pkg = metadata.packages.find((p) => {
// package with given name
if (options.package) {
return p.name === options.package
} else {
return p.manifest_path === manifestPath
}
})
if (!pkg) {
throw new Error(
'Unable to find crate to build. It seems you are trying to build a crate in a workspace, try using `--package` option to specify the package to build.',
)
}
const crateDir = parse(pkg.manifest_path).dir
const builder = new Builder(
options,
pkg,
cwd,
options.target
? parseTriple(options.target)
: process.env.CARGO_BUILD_TARGET
? parseTriple(process.env.CARGO_BUILD_TARGET)
: getSystemDefaultTarget(),
crateDir,
resolvePath(options.outputDir ?? crateDir),
options.targetDir ??
process.env.CARGO_BUILD_TARGET_DIR ??
metadata.target_directory,
await readNapiConfig(
resolvePath(
options.configPath ?? options.packageJsonPath ?? 'package.json',
),
options.configPath ? resolvePath(options.configPath) : undefined,
),
)
return builder.build()
}
class Builder {
private readonly args: string[] = []
private readonly envs: Record<string, string> = {}
private readonly outputs: Output[] = []
constructor(
private readonly options: BuildOptions,
private readonly crate: Crate,
private readonly cwd: string,
private readonly target: Target,
private readonly crateDir: string,
private readonly outputDir: string,
private readonly targetDir: string,
private readonly config: NapiConfig,
) {}
get cdyLibName() {
return this.crate.targets.find((t) => t.crate_types.includes('cdylib'))
?.name
}
get binName() {
return (
this.options.bin ??
// only available if not cdylib or bin name specified
(this.cdyLibName
? null
: this.crate.targets.find((t) => t.crate_types.includes('bin'))?.name)
)
}
build() {
if (!this.cdyLibName) {
const warning =
'Missing `crate-type = ["cdylib"]` in [lib] config. The build result will not be available as node addon.'
if (this.binName) {
debug.warn(warning)
} else {
throw new Error(warning)
}
}
return this.pickBinary()
.setPackage()
.setFeatures()
.setTarget()
.pickCrossToolchain()
.setEnvs()
.setBypassArgs()
.exec()
}
private pickCrossToolchain() {
if (!this.options.useNapiCross) {
return this
}
if (this.options.useCross) {
debug.warn(
'You are trying to use both `--cross` and `--use-napi-cross` options, `--use-cross` will be ignored.',
)
}
if (this.options.crossCompile) {
debug.warn(
'You are trying to use both `--cross-compile` and `--use-napi-cross` options, `--cross-compile` will be ignored.',
)
}
try {
const { version, download } = require('@napi-rs/cross-toolchain')
const toolchainPath = join(
homedir(),
'.napi-rs',
'cross-toolchain',
version,
this.target.triple,
)
mkdirSync(toolchainPath, { recursive: true })
if (existsSync(join(toolchainPath, 'package.json'))) {
debug(`Toolchain ${toolchainPath} exists, skip extracting`)
} else {
const tarArchive = download(process.arch, this.target.triple)
tarArchive.unpack(toolchainPath)
}
const upperCaseTarget = targetToEnvVar(this.target.triple)
const linkerEnv = `CARGO_TARGET_${upperCaseTarget}_LINKER`
this.envs[linkerEnv] = join(
toolchainPath,
'bin',
`${this.target.triple}-gcc`,
)
if (!process.env.TARGET_SYSROOT) {
this.envs[`TARGET_SYSROOT`] = join(
toolchainPath,
this.target.triple,
'sysroot',
)
}
if (!process.env.TARGET_AR) {
this.envs[`TARGET_AR`] = join(
toolchainPath,
'bin',
`${this.target.triple}-ar`,
)
}
if (!process.env.TARGET_RANLIB) {
this.envs[`TARGET_RANLIB`] = join(
toolchainPath,
'bin',
`${this.target.triple}-ranlib`,
)
}
if (!process.env.TARGET_READELF) {
this.envs[`TARGET_READELF`] = join(
toolchainPath,
'bin',
`${this.target.triple}-readelf`,
)
}
if (!process.env.TARGET_C_INCLUDE_PATH) {
this.envs[`TARGET_C_INCLUDE_PATH`] = join(
toolchainPath,
this.target.triple,
'sysroot',
'usr',
'include/',
)
}
if (!process.env.CC && !process.env.TARGET_CC) {
this.envs[`CC`] = join(
toolchainPath,
'bin',
`${this.target.triple}-gcc`,
)
this.envs[`TARGET_CC`] = join(
toolchainPath,
'bin',
`${this.target.triple}-gcc`,
)
}
if (!process.env.CXX && !process.env.TARGET_CXX) {
this.envs[`CXX`] = join(
toolchainPath,
'bin',
`${this.target.triple}-g++`,
)
this.envs[`TARGET_CXX`] = join(
toolchainPath,
'bin',
`${this.target.triple}-g++`,
)
}
if (
(process.env.CC === 'clang' &&
(process.env.TARGET_CC === 'clang' || !process.env.TARGET_CC)) ||
process.env.TARGET_CC === 'clang'
) {
this.envs.CFLAGS = `--sysroot=${this.envs.TARGET_SYSROOT}`
}
if (
(process.env.CXX === 'clang++' &&
(process.env.TARGET_CXX === 'clang++' || !process.env.TARGET_CXX)) ||
process.env.TARGET_CXX === 'clang++'
) {
this.envs.CXXFLAGS = `--sysroot=${this.envs.TARGET_SYSROOT}`
}
} catch (e) {
debug.warn('Pick cross toolchain failed', e as Error)
// ignore, do nothing
}
return this
}
private exec() {
debug(`Start building crate: ${this.crate.name}`)
debug(' %i', `cargo ${this.args.join(' ')}`)
const controller = new AbortController()
const watch = this.options.watch
const buildTask = new Promise<void>((resolve, reject) => {
if (this.options.useCross && this.options.crossCompile) {
throw new Error(
'`--use-cross` and `--cross-compile` can not be used together',
)
}
const command =
process.env.CARGO ?? (this.options.useCross ? 'cross' : 'cargo')
const buildProcess = spawn(command, this.args, {
env: {
...process.env,
...this.envs,
},
stdio: watch ? ['inherit', 'inherit', 'pipe'] : 'inherit',
cwd: this.cwd,
signal: controller.signal,
})
buildProcess.once('exit', (code) => {
if (code === 0) {
debug('%i', `Build crate ${this.crate.name} successfully!`)
resolve()
} else {
reject(new Error(`Build failed with exit code ${code}`))
}
})
buildProcess.once('error', (e) => {
reject(
new Error(`Build failed with error: ${e.message}`, {
cause: e,
}),
)
})
// watch mode only, they are piped through stderr
buildProcess.stderr?.on('data', (data) => {
const output = data.toString()
console.error(output)
if (/Finished\s(dev|release)/.test(output)) {
this.postBuild().catch(() => {})
}
})
})
return {
task: buildTask.then(() => this.postBuild()),
abort: () => controller.abort(),
}
}
private pickBinary() {
let set = false
if (this.options.watch) {
if (process.env.CI) {
debug.warn('Watch mode is not supported in CI environment')
} else {
debug('Use %i', 'cargo-watch')
tryInstallCargoBinary('cargo-watch', 'watch')
// yarn napi watch --target x86_64-unknown-linux-gnu [--cross-compile]
// ===>
// cargo watch [...] -- build --target x86_64-unknown-linux-gnu
// cargo watch [...] -- zigbuild --target x86_64-unknown-linux-gnu
this.args.push(
'watch',
'--why',
'-i',
'*.{js,ts,node}',
'-w',
this.crateDir,
'--',
'cargo',
'build',
)
set = true
}
}
if (this.options.crossCompile) {
if (this.target.platform === 'win32') {
if (process.platform === 'win32') {
debug.warn(
'You are trying to cross compile to win32 platform on win32 platform which is unnecessary.',
)
} else {
// use cargo-xwin to cross compile to win32 platform
debug('Use %i', 'cargo-xwin')
tryInstallCargoBinary('cargo-xwin', 'xwin')
this.args.push('xwin', 'build')
if (this.target.arch === 'ia32') {
this.envs.XWIN_ARCH = 'x86'
}
set = true
}
} else {
if (
this.target.platform === 'linux' &&
process.platform === 'linux' &&
this.target.arch === process.arch &&
(function (abi: string | null) {
const glibcVersionRuntime =
// @ts-expect-error
process.report?.getReport()?.header?.glibcVersionRuntime
const libc = glibcVersionRuntime ? 'gnu' : 'musl'
return abi === libc
})(this.target.abi)
) {
debug.warn(
'You are trying to cross compile to linux target on linux platform which is unnecessary.',
)
} else if (
this.target.platform === 'darwin' &&
process.platform === 'darwin'
) {
debug.warn(
'You are trying to cross compile to darwin target on darwin platform which is unnecessary.',
)
} else {
// use cargo-zigbuild to cross compile to other platforms
debug('Use %i', 'cargo-zigbuild')
tryInstallCargoBinary('cargo-zigbuild', 'zigbuild')
this.args.push('zigbuild')
set = true
}
}
}
if (!set) {
this.args.push('build')
}
return this
}
private setPackage() {
const args = []
if (this.options.package) {
args.push('--package', this.options.package)
}
if (this.binName) {
args.push('--bin', this.binName)
}
if (args.length) {
debug('Set package flags: ')
debug(' %O', args)
this.args.push(...args)
}
return this
}
private setTarget() {
debug('Set compiling target to: ')
debug(' %i', this.target.triple)
this.args.push('--target', this.target.triple)
return this
}
private setEnvs() {
// type definition intermediate file
this.envs.TYPE_DEF_TMP_PATH = this.getIntermediateTypeFile()
// WASI register intermediate file
this.envs.WASI_REGISTER_TMP_PATH = this.getIntermediateWasiRegisterFile()
// TODO:
// remove after napi-derive@v3 release
this.envs.CARGO_CFG_NAPI_RS_CLI_VERSION = CLI_VERSION
// RUSTFLAGS
let rustflags =
process.env.RUSTFLAGS ?? process.env.CARGO_BUILD_RUSTFLAGS ?? ''
if (
this.target.abi?.includes('musl') &&
!rustflags.includes('target-feature=-crt-static')
) {
rustflags += ' -C target-feature=-crt-static'
}
if (this.options.strip && !rustflags.includes('link-arg=-s')) {
rustflags += ' -C link-arg=-s'
}
if (rustflags.length) {
this.envs.RUSTFLAGS = rustflags
}
// END RUSTFLAGS
// LINKER
const linker = this.options.crossCompile
? void 0
: getTargetLinker(this.target.triple)
// TODO:
// directly set CARGO_TARGET_<target>_LINKER will cover .cargo/config.toml
// will detect by cargo config when it becomes stable
// see: https://github.com/rust-lang/cargo/issues/9301
const linkerEnv = `CARGO_TARGET_${targetToEnvVar(
this.target.triple,
)}_LINKER`
if (linker && !process.env[linkerEnv]) {
this.envs[linkerEnv] = linker
}
if (this.target.platform === 'android') {
const { ANDROID_NDK_LATEST_HOME } = process.env
if (!ANDROID_NDK_LATEST_HOME) {
debug.warn(
`${colors.red(
'ANDROID_NDK_LATEST_HOME',
)} environment variable is missing`,
)
}
const targetArch = this.target.arch === 'arm' ? 'armv7a' : 'aarch64'
const targetPlatform =
this.target.arch === 'arm' ? 'androideabi24' : 'android24'
const hostPlatform =
process.platform === 'darwin'
? 'darwin'
: process.platform === 'win32'
? 'windows'
: 'linux'
Object.assign(this.envs, {
CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-android24-clang`,
CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-androideabi24-clang`,
CC: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-${targetPlatform}-clang`,
CXX: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/${targetArch}-linux-${targetPlatform}-clang++`,
AR: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/llvm-ar`,
RANLIB: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin/llvm-ranlib`,
ANDROID_NDK: ANDROID_NDK_LATEST_HOME,
PATH: `${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/${hostPlatform}-x86_64/bin:${process.env.PATH}`,
})
}
// END LINKER
if (this.target.platform === 'wasi') {
const emnapi = join(
require.resolve('emnapi'),
'..',
'lib',
'wasm32-wasi-threads',
)
this.envs.EMNAPI_LINK_DIR = emnapi
this.envs.SETJMP_LINK_DIR = setjmpLib
const { WASI_SDK_PATH } = process.env
if (WASI_SDK_PATH && existsSync(WASI_SDK_PATH)) {
this.envs.CARGO_TARGET_WASM32_WASI_PREVIEW1_THREADS_LINKER = join(
WASI_SDK_PATH,
'bin',
'wasm-ld',
)
this.setEnvIfNotExists('CC', join(WASI_SDK_PATH, 'bin', 'clang'))
this.setEnvIfNotExists('CXX', join(WASI_SDK_PATH, 'bin', 'clang++'))
this.setEnvIfNotExists('AR', join(WASI_SDK_PATH, 'bin', 'ar'))
this.setEnvIfNotExists('RANLIB', join(WASI_SDK_PATH, 'bin', 'ranlib'))
this.setEnvIfNotExists(
'CFLAGS',
`--target=wasm32-wasi-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj -I${setjmpInclude}`,
)
this.setEnvIfNotExists(
'CXXFLAGS',
`--target=wasm32-wasi-threads --sysroot=${WASI_SDK_PATH}/share/wasi-sysroot -pthread -mllvm -wasm-enable-sjlj -I${setjmpInclude}`,
)
this.setEnvIfNotExists(
`LDFLAGS`,
`-fuse-ld=${WASI_SDK_PATH}/bin/wasm-ld --target=wasm32-wasi-threads`,
)
}
}
debug('Set envs: ')
Object.entries(this.envs).forEach(([k, v]) => {
debug(' %i', `${k}=${v}`)
})
return this
}
private setFeatures() {
const args = []
if (this.options.allFeatures && this.options.allFeatures) {
throw new Error(
'Cannot specify --all-features and --no-default-features together',
)
}
if (this.options.allFeatures) {
args.push('--all-features')
} else if (this.options.noDefaultFeatures) {
args.push('--no-default-features')
}
if (this.options.features) {
args.push('--features', ...this.options.features)
}
debug('Set features flags: ')
debug(' %O', args)
this.args.push(...args)
return this
}
private setBypassArgs() {
if (this.options.release) {
this.args.push('--release')
}
if (this.options.verbose) {
this.args.push('--verbose')
}
if (this.options.targetDir) {
this.args.push('--target-dir', this.options.targetDir)
}
if (this.options.profile) {
this.args.push('--profile', this.options.profile)
}
if (this.options.manifestPath) {
this.args.push('--manifest-path', this.options.manifestPath)
}
if (this.options.cargoOptions?.length) {
this.args.push(...this.options.cargoOptions)
}
return this
}
private getIntermediateTypeFile() {
return join(
tmpdir(),
`${this.crate.name}-${createHash('sha256')
.update(this.crate.manifest_path)
.update(CLI_VERSION)
.digest('hex')
.substring(0, 8)}.napi_type_def.tmp`,
)
}
private getIntermediateWasiRegisterFile() {
return join(
tmpdir(),
`${this.crate.name}-${createHash('sha256')
.update(this.crate.manifest_path)
.update(CLI_VERSION)
.digest('hex')
.substring(0, 8)}.napi_wasi_register.tmp`,
)
}
private async postBuild() {
try {
debug(`Try to create output directory:`)
debug(' %i', this.outputDir)
await mkdirAsync(this.outputDir, { recursive: true })
debug(`Output directory created`)
} catch (e) {
throw new Error(`Failed to create output directory ${this.outputDir}`, {
cause: e,
})
}
const dest = await this.copyArtifact()
// only for cdylib
if (this.cdyLibName) {
const idents = await this.generateTypeDef()
const intermediateWasiRegisterFile = this.envs.WASI_REGISTER_TMP_PATH
const wasiRegisterFunctions =
this.target.arch === 'wasm32'
? await (async function readIntermediateWasiRegisterFile() {
const fileContent = await readFileAsync(
intermediateWasiRegisterFile,
'utf8',
).catch((err) => {
console.warn(
`Read ${colors.yellowBright(
intermediateWasiRegisterFile,
)} failed, reason: ${err.message}`,
)
return ``
})
return fileContent
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length)
.map((line) => {
const [_, fn] = line.split(':')
return fn.trim()
})
})()
: []
const jsOutput = await this.writeJsBinding(idents)
const wasmBindingsOutput = await this.writeWasiBinding(
wasiRegisterFunctions,
dest ?? 'index.wasm',
idents,
)
if (jsOutput) {
this.outputs.push(jsOutput)
}
if (wasmBindingsOutput) {
this.outputs.push(...wasmBindingsOutput)
}
}
return this.outputs
}
private async copyArtifact() {
const [srcName, destName] = this.getArtifactNames()
if (!srcName || !destName) {
return
}
const profile =
this.options.profile ?? (this.options.release ? 'release' : 'debug')
const src = join(this.targetDir, this.target.triple, profile, srcName)
debug(`Copy artifact from: [${src}]`)
const dest = join(this.outputDir, destName)
try {
if (await fileExists(dest)) {
debug('Old artifact found, remove it first')
await unlinkAsync(dest)
}
debug('Copy artifact to:')
debug(' %i', dest)
await copyFileAsync(src, dest)
this.outputs.push({
kind: dest.endsWith('.node') ? 'node' : 'exe',
path: dest,
})
return dest
} catch (e) {
throw new Error('Failed to copy artifact', {
cause: e,
})
}
}
private getArtifactNames() {
if (this.cdyLibName) {
const cdyLib = this.cdyLibName.replace(/-/g, '_')
const srcName =
this.target.platform === 'darwin'
? `lib${cdyLib}.dylib`
: this.target.platform === 'win32'
? `${cdyLib}.dll`
: this.target.platform === 'wasi' || this.target.platform === 'wasm'
? `${cdyLib}.wasm`
: `lib${cdyLib}.so`
let destName = this.config.binaryName
// add platform suffix to binary name
// index[.linux-x64-gnu].node
// ^^^^^^^^^^^^^^
if (this.options.platform) {
destName += `.${this.target.platformArchABI}`
}
if (srcName.endsWith('.wasm')) {
destName += '.wasm'
} else {
destName += '.node'
}
return [srcName, destName]
} else if (this.binName) {
const srcName =
this.target.platform === 'win32' ? `${this.binName}.exe` : this.binName
return [srcName, srcName]
}
return []
}
private async generateTypeDef() {
if (!(await fileExists(this.envs.TYPE_DEF_TMP_PATH))) {
return []
}
const dest = join(this.outputDir, this.options.dts ?? 'index.d.ts')
const { dts, exports } = await processTypeDef(
this.envs.TYPE_DEF_TMP_PATH,
this.options.constEnum ?? true,
!this.options.noDtsHeader
? this.options.dtsHeader ?? DEFAULT_TYPE_DEF_HEADER
: '',
)
try {
debug('Writing type def to:')
debug(' %i', dest)
await writeFileAsync(dest, dts, 'utf-8')
this.outputs.push({
kind: 'dts',
path: dest,
})
} catch (e) {
debug.error('Failed to write type def file')
debug.error(e as Error)
}
return exports
}
private async writeJsBinding(idents: string[]) {
if (
!this.options.platform ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
this.options.noJsBinding ||
idents.length === 0
) {
return
}
const name = this.options.jsBinding ?? 'index.js'
const cjs = createCjsBinding(
this.config.binaryName,
this.config.packageName,
idents,
)
try {
const dest = join(this.outputDir, name)
debug('Writing js binding to:')
debug(' %i', dest)
await writeFileAsync(dest, cjs, 'utf-8')
return {
kind: 'js',
path: dest,
} satisfies Output
} catch (e) {
throw new Error('Failed to write js binding file', { cause: e })
}
}
private async writeWasiBinding(
wasiRegisterFunctions: string[],
distFileName: string | undefined,
idents: string[],
) {
if (distFileName && wasiRegisterFunctions.length) {
const { name, dir } = parse(distFileName)
const bindingPath = join(dir, `${this.config.binaryName}.wasi.cjs`)
const browserBindingPath = join(
dir,
`${this.config.binaryName}.wasi-browser.js`,
)
const workerPath = join(dir, 'wasi-worker.mjs')
const browserWorkerPath = join(dir, 'wasi-worker-browser.mjs')
const browserEntryPath = join(dir, 'browser.js')
const exportsCode = idents
.map(
(ident) => `module.exports.${ident} = __napiModule.exports.${ident}`,
)
.join('\n')
await writeFileAsync(
bindingPath,
createWasiBinding(
name,
this.config.packageName,
wasiRegisterFunctions,
) +
exportsCode +
'\n',
'utf8',
)
await writeFileAsync(
browserBindingPath,
createWasiBrowserBinding(name, wasiRegisterFunctions) +
idents
.map(
(ident) =>
`export const ${ident} = __napiModule.exports.${ident}`,
)
.join('\n') +
'\n',
'utf8',
)
await writeFileAsync(workerPath, WASI_WORKER_TEMPLATE, 'utf8')
await writeFileAsync(
browserWorkerPath,
WASI_WORKER_BROWSER_TEMPLATE,
'utf8',
)
await writeFileAsync(
browserEntryPath,
`export * from '${this.config.packageName}-wasm32-wasi'\n`,
)
return [
{
kind: 'js',
path: bindingPath,
},
{
kind: 'js',
path: browserBindingPath,
},
{
kind: 'js',
path: workerPath,
},
{
kind: 'js',
path: browserWorkerPath,
},
{
kind: 'js',
path: browserEntryPath,
},
] satisfies Output[]
}
return []
}
private setEnvIfNotExists(env: string, value: string) {
if (!process.env[env]) {
this.envs[env] = value
}
}
}