UNPKG

@napi-rs/cli

Version:

Cli tools for napi-rs

906 lines (817 loc) 27.6 kB
import { exec, execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' import { promises as fs } from 'node:fs' import { parse as parseToml, stringify as stringifyToml } from '@std/toml' import { load as yamlLoad, dump as yamlDump } from 'js-yaml' import { applyDefaultNewOptions, type NewOptions as RawNewOptions, } from '../def/new.js' import { AVAILABLE_TARGETS, CLI_VERSION, debugFactory, DEFAULT_TARGETS, getWasiTarget, mkdirAsync, parseTriple, readdirAsync, statAsync, wasiTargetHasThreads, type SupportedPackageManager, } from '../utils/index.js' import { napiEngineRequirement, SUPPORTED_NAPI_VERSIONS, restrictWasiNodeEngine, } from '../utils/version.js' import { renameProject } from './rename.js' import { createCjsBinding } from './templates/index.js' // Template imports removed as we're now using external templates const debug = debugFactory('new') type NewOptions = Required<RawNewOptions> const TEMPLATE_REPOS = { yarn: 'https://github.com/napi-rs/package-template', pnpm: 'https://github.com/napi-rs/package-template-pnpm', } as const const TEMPLATE_LOCKFILES: Record<SupportedPackageManager, string> = { yarn: 'yarn.lock', pnpm: 'pnpm-lock.yaml', } const WASI_CI_ARTIFACT_PATTERNS = [ 'index.js', 'browser.js', '*.wasi*.cjs', '*.wasi*.d.cts', '*.wasi*-browser.js', '*.wasi*-deferred.js', '*.wasi*-deferred.d.ts', 'wasi-worker*.mjs', ] // Exported for tests. export const GENERATED_WASI_BINDING = /(?:\.(?:wasi|wasip\d+)(?:-browser|-deferred)?\.(?:cjs|js|d\.[cm]?ts)|wasi-worker(?:-browser)?\.mjs)/ const TYPE_DEF_FILE = /\.d\.[cm]?ts$/ const GLOB_PATTERN = /[*?[\]{}]/ function createWasiBrowserEntry( packageName: string, platformArchABI: string, enableTypeDef: boolean, ) { const packageSpecifier = `${packageName}-${platformArchABI}` return ( `export * from '${packageSpecifier}'\n` + (enableTypeDef ? '' : `export { default } from '${packageSpecifier}'\n`) ) } function stripTypeConditions( value: unknown, typeDefPaths: Set<string>, ): unknown { if (Array.isArray(value)) { const entries = value .map((entry) => stripTypeConditions(entry, typeDefPaths)) .filter((entry) => entry !== undefined) return entries.length > 0 ? entries : undefined } if (typeof value !== 'object' || value === null) { return value } const result: Record<string, unknown> = {} for (const [key, entry] of Object.entries(value)) { if (key === 'types') { if (typeof entry === 'string') { typeDefPaths.add(entry) } continue } const stripped = stripTypeConditions(entry, typeDefPaths) if (stripped !== undefined) { result[key] = stripped } } return Object.keys(result).length > 0 ? result : undefined } async function removeTypeDefOutput( packageJson: Record<string, any>, packageDir: string, ) { const typeDefPaths = new Set<string>(['index.d.ts']) for (const field of ['types', 'typings']) { if (typeof packageJson[field] === 'string') { typeDefPaths.add(packageJson[field]) } delete packageJson[field] } delete packageJson.typesVersions if (packageJson.exports !== undefined) { const exports = stripTypeConditions(packageJson.exports, typeDefPaths) if (exports === undefined) { delete packageJson.exports } else { packageJson.exports = exports } } if (Array.isArray(packageJson.files)) { packageJson.files = packageJson.files.filter((file: unknown) => { if (typeof file !== 'string' || !TYPE_DEF_FILE.test(file)) { return true } typeDefPaths.add(file) return false }) } for (const typeDefPath of typeDefPaths) { const relativePath = typeDefPath.replace(/^\.\//, '') if (!TYPE_DEF_FILE.test(relativePath) || GLOB_PATTERN.test(relativePath)) { continue } const absolutePath = path.resolve(packageDir, relativePath) const relativeToPackage = path.relative(packageDir, absolutePath) if ( relativeToPackage === '' || relativeToPackage.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToPackage) ) { continue } await fs.rm(absolutePath, { force: true }) } } async function checkGitCommand(): Promise<boolean> { return new Promise<boolean>((resolve) => { const cp = exec('git --version') cp.on('error', () => { resolve(false) }) cp.on('exit', (code) => { resolve(code === 0) }) }) } async function ensureCacheDir( packageManager: SupportedPackageManager, ): Promise<string> { const cacheDir = path.join(homedir(), '.napi-rs', 'template', packageManager) await mkdirAsync(cacheDir, { recursive: true }) return cacheDir } async function downloadTemplate( packageManager: SupportedPackageManager, cacheDir: string, ): Promise<void> { const repoUrl = TEMPLATE_REPOS[packageManager] const templatePath = path.join(cacheDir, 'repo') if (existsSync(templatePath)) { debug(`Template cache found at ${templatePath}, updating...`) try { // Fetch latest changes and reset to remote await new Promise<void>((resolve, reject) => { const cp = exec('git fetch origin', { cwd: templatePath }) cp.on('error', reject) cp.on('exit', (code) => { if (code === 0) { resolve() } else { reject( new Error( `Failed to fetch latest changes, git process exited with code ${code}`, ), ) } }) }) execSync('git reset --hard origin/main', { cwd: templatePath, stdio: 'ignore', }) debug('Template updated successfully') } catch (error) { debug(`Failed to update template: ${error}`) throw new Error(`Failed to update template from ${repoUrl}: ${error}`) } } else { debug(`Cloning template from ${repoUrl}...`) try { execSync(`git clone ${repoUrl} repo`, { cwd: cacheDir, stdio: 'inherit' }) debug('Template cloned successfully') } catch (error) { throw new Error(`Failed to clone template from ${repoUrl}: ${error}`) } } } async function copyDirectory( src: string, dest: string, hasWasiTargets: boolean, ): Promise<void> { await mkdirAsync(dest, { recursive: true }) const entries = await fs.readdir(src, { withFileTypes: true }) for (const entry of entries) { const srcPath = path.join(src, entry.name) const destPath = path.join(dest, entry.name) // Skip .git directory if (entry.name === '.git') { continue } if (entry.isDirectory()) { await copyDirectory(srcPath, destPath, hasWasiTargets) } else { if ( entry.name === 'browser.js' || GENERATED_WASI_BINDING.test(entry.name) || (hasWasiTargets && entry.name === 'index.js') ) { continue } await fs.copyFile(srcPath, destPath) } } } async function filterTargetsInPackageJson( filePath: string, enabledTargets: string[], enableTypeDef: boolean, packageManager: SupportedPackageManager, ): Promise<void> { const content = await fs.readFile(filePath, 'utf-8') const packageJson = JSON.parse(content) const wasiTargets = enabledTargets .map(parseTriple) .filter((target) => target.platform === 'wasi') const includeWasiBindings = wasiTargets.length > 0 const includeThreadlessWasi = wasiTargets.some( (target) => !wasiTargetHasThreads(target), ) // The external templates may not yet list newly supported targets. The // requested target set is authoritative rather than a filter over the // template's current contents. packageJson.napi ??= {} packageJson.napi.targets = enabledTargets if (includeThreadlessWasi) { packageJson.devDependencies ??= {} packageJson.devDependencies['@napi-rs/cli'] = `^${CLI_VERSION}` // Template lockfiles can pin an older CLI that predates this target even // when package.json's range is updated. The first install must resolve a // lockfile from the generated manifest. await fs.rm( path.join(path.dirname(filePath), TEMPLATE_LOCKFILES[packageManager]), { force: true }, ) } if (!enableTypeDef) { await removeTypeDefOutput(packageJson, path.dirname(filePath)) } if (includeWasiBindings) { packageJson.browser = 'browser.js' packageJson.files ??= [] if (!packageJson.files.includes('browser.js')) { packageJson.files.push('browser.js') } const browserTarget = wasiTargets.find( (target) => getWasiTarget(target)?.flavor === 'single', ) ?? wasiTargets[0] await fs.writeFile( path.join(path.dirname(filePath), 'browser.js'), createWasiBrowserEntry( packageJson.name, browserTarget.platformArchABI, enableTypeDef, ), ) if (!enableTypeDef) { const wasiFlavors = [ ...wasiTargets.filter(wasiTargetHasThreads), ...wasiTargets.filter((target) => !wasiTargetHasThreads(target)), ].map((target) => target.platformArchABI) packageJson.main = 'index.js' if ( !packageJson.files.includes('index.js') && !packageJson.files.includes('./index.js') ) { packageJson.files.push('index.js') } await fs.writeFile( path.join(path.dirname(filePath), 'index.js'), createCjsBinding( packageJson.napi.binaryName, packageJson.napi.packageName ?? packageJson.name, [], packageJson.version, wasiFlavors, ), ) } } else { if ( packageJson.browser === 'browser.js' || packageJson.browser === './browser.js' ) { delete packageJson.browser } if (Array.isArray(packageJson.files)) { packageJson.files = packageJson.files.filter( (file: unknown) => file !== 'browser.js' && file !== './browser.js', ) } } await fs.writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n') await updateGeneratedWasiAttributes( path.join(path.dirname(filePath), '.gitattributes'), packageJson.napi.binaryName, wasiTargets, ) } async function updateGeneratedWasiAttributes( filePath: string, binaryName: string, wasiTargets: ReturnType<typeof parseTriple>[], ) { if (!existsSync(filePath)) { return } const lines = (await fs.readFile(filePath, 'utf8')) .split('\n') .filter((line) => !GENERATED_WASI_BINDING.test(line)) const generatedFiles = new Set<string>() for (const target of wasiTargets) { const suffix = target.platformArchABI.replace(/^wasm32-/, '') generatedFiles.add(`${binaryName}.${suffix}.cjs`) generatedFiles.add(`${binaryName}.${suffix}.d.cts`) generatedFiles.add(`${binaryName}.${suffix}-browser.js`) if (getWasiTarget(target)?.flavor === 'threads') { generatedFiles.add('wasi-worker.mjs') generatedFiles.add('wasi-worker-browser.mjs') } else { generatedFiles.add(`${binaryName}.${suffix}-deferred.js`) generatedFiles.add(`${binaryName}.${suffix}-deferred.d.ts`) } } if (generatedFiles.size > 0) { while (lines[lines.length - 1] === '') { lines.pop() } lines.push( '', ...[...generatedFiles].map((file) => `${file} linguist-detectable=false`), ) } await fs.writeFile(filePath, `${lines.join('\n')}\n`) } async function updateCargoTomlTypeDef( filePath: string, enableTypeDef: boolean, ): Promise<void> { if (enableTypeDef) { return } const content = await fs.readFile(filePath, 'utf-8') const cargoToml = parseToml(content) as Record<string, any> const dependencies = cargoToml.dependencies if (!dependencies || !dependencies['napi-derive']) { return } const napiDeriveDependency = dependencies['napi-derive'] const dependencyConfig = typeof napiDeriveDependency === 'string' ? { version: napiDeriveDependency } : { ...napiDeriveDependency } const existingFeatures: string[] = Array.isArray(dependencyConfig.features) ? dependencyConfig.features.filter( (feature: unknown): feature is string => typeof feature === 'string', ) : [] dependencyConfig['default-features'] = false dependencyConfig.features = [ 'strict', ...existingFeatures.filter((feature) => feature !== 'strict'), ].filter((feature) => feature !== 'type-def') dependencies['napi-derive'] = dependencyConfig await fs.writeFile(filePath, stringifyToml(cargoToml)) } export async function updateCargoTomlNodeApiVersion( filePath: string, minNodeApiVersion: number, ): Promise<void> { const content = await fs.readFile(filePath, 'utf-8') const cargoToml = parseToml(content) as Record<string, any> const dependencies = cargoToml.dependencies if (!dependencies || !dependencies.napi) { return } const napiDependency = dependencies.napi const dependencyConfig = typeof napiDependency === 'string' ? { version: napiDependency } : { ...napiDependency } const usedDefaultFeatures = dependencyConfig['default-features'] !== false const existingFeatures: string[] = Array.isArray(dependencyConfig.features) ? dependencyConfig.features.filter( (feature: unknown): feature is string => typeof feature === 'string', ) : [] dependencyConfig.features = [ `napi${minNodeApiVersion}`, ...existingFeatures.filter((feature) => !/^napi\d+$/.test(feature)), ] // napi's default features include napi4. Disable them for lower requested // levels, while retaining the default dynamic Node-API symbol loading mode. if (minNodeApiVersion < 4) { dependencyConfig['default-features'] = false if ( usedDefaultFeatures && !dependencyConfig.features.includes('dyn-symbols') ) { dependencyConfig.features.push('dyn-symbols') } } dependencies.napi = dependencyConfig await fs.writeFile(filePath, stringifyToml(cargoToml)) } async function filterTargetsInGithubActions( filePath: string, enabledTargets: string[], ): Promise<void> { const content = await fs.readFile(filePath, 'utf-8') const yaml = yamlLoad(content) as any const linuxTargets = new Set([ 'x86_64-unknown-linux-gnu', 'x86_64-unknown-linux-musl', 'aarch64-unknown-linux-gnu', 'aarch64-unknown-linux-musl', 'armv7-unknown-linux-gnueabihf', 'armv7-unknown-linux-musleabihf', 'loongarch64-unknown-linux-gnu', 'riscv64gc-unknown-linux-gnu', 'powerpc64le-unknown-linux-gnu', 's390x-unknown-linux-gnu', 'aarch64-linux-android', 'armv7-linux-androideabi', ]) // Check if any Linux targets are enabled const hasLinuxTargets = enabledTargets.some((target) => linuxTargets.has(target), ) const hasMacOSOrWindowsTargets = enabledTargets.some((target) => { const platform = parseTriple(target).platform return platform === 'darwin' || platform === 'win32' }) const wasiTargets = enabledTargets.filter((target) => getWasiTarget(target)) // Filter the matrix configurations in the build job if (yaml?.jobs?.build?.strategy?.matrix?.settings) { const settings = yaml.jobs.build.strategy.matrix.settings const wasiTemplate = settings.find( (setting: any) => setting.target && getWasiTarget(setting.target), ) const wasiTemplateTarget = typeof wasiTemplate?.target === 'string' ? wasiTemplate.target : undefined const wasiTargetComparison = wasiTemplateTarget ? new RegExp( `matrix\\.settings\\.target\\s*(==|!=)\\s*(['"])${wasiTemplateTarget.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\2`, ) : undefined const filteredSettings = settings.filter((setting: any) => { if (setting.target && getWasiTarget(setting.target)) { return false } if (setting.target) { return enabledTargets.includes(setting.target) } return true }) if (wasiTargets.length > 0 && !wasiTemplate) { throw new Error('Template CI is missing a WASI build matrix entry') } for (const target of wasiTargets) { const setting = { ...wasiTemplate } const templateTarget = setting.target setting.target = target if (typeof setting.build === 'string') { setting.build = setting.build.replaceAll(templateTarget, target) } filteredSettings.push(setting) } yaml.jobs.build.strategy.matrix.settings = filteredSettings for (const step of yaml.jobs.build.steps ?? []) { const operator = typeof step.if === 'string' && wasiTargetComparison ? step.if.match(wasiTargetComparison)?.[1] : undefined if (operator === '!=') { step.if = "${{ !startsWith(matrix.settings.target, 'wasm32-') }}" } else if (operator === '==') { step.if = "${{ startsWith(matrix.settings.target, 'wasm32-') }}" } if ( wasiTargets.length > 0 && typeof step.uses === 'string' && step.uses.startsWith('actions/upload-artifact@') && typeof step.with?.path === 'string' && step.with.path.includes('.wasm') ) { const existingPatterns = step.with.path .split('\n') .map((pattern: string) => pattern.trim()) .filter(Boolean) step.with.path = [ ...new Set([...existingPatterns, ...WASI_CI_ARTIFACT_PATTERNS]), ].join('\n') } } } const jobsToRemove: string[] = [] if (!hasMacOSOrWindowsTargets) { jobsToRemove.push('test-macOS-windows-binding') } else { // Filter the matrix configurations in the test-macOS-windows-binding job if ( yaml?.jobs?.['test-macOS-windows-binding']?.strategy?.matrix?.settings ) { yaml.jobs['test-macOS-windows-binding'].strategy.matrix.settings = yaml.jobs['test-macOS-windows-binding'].strategy.matrix.settings.filter( (setting: any) => { if (setting.target) { return enabledTargets.includes(setting.target) } return true }, ) } } // If no Linux targets are enabled, remove Linux-specific jobs if (!hasLinuxTargets) { // Remove test-linux-binding job if (yaml?.jobs?.['test-linux-binding']) { jobsToRemove.push('test-linux-binding') } } else { // Filter the matrix configurations in the test-linux-x64-gnu-binding job if (yaml?.jobs?.['test-linux-binding']?.strategy?.matrix?.target) { yaml.jobs['test-linux-binding'].strategy.matrix.target = yaml.jobs[ 'test-linux-binding' ].strategy.matrix.target.filter((target: string) => { if (target) { return enabledTargets.includes(target) } return true }) } } if (wasiTargets.length === 0) { jobsToRemove.push('test-wasi') } else if (yaml.jobs?.['test-wasi']) { const wasiJob = yaml.jobs['test-wasi'] if (wasiTargets.length > 1) { wasiJob.strategy = { 'fail-fast': false, matrix: { target: wasiTargets, }, } wasiJob.name = 'Test WASI target - ${{ matrix.target }}' } const downloadStep = wasiJob.steps?.find( (step: any) => typeof step.uses === 'string' && step.uses.startsWith('actions/download-artifact@'), ) if (downloadStep?.with) { downloadStep.with.name = wasiTargets.length === 1 ? `bindings-${wasiTargets[0]}` : 'bindings-${{ matrix.target }}' } for (const step of wasiJob.steps ?? []) { if ( step.env && Object.prototype.hasOwnProperty.call(step.env, 'NAPI_RS_FORCE_WASI') ) { step.env.NAPI_RS_FORCE_WASI = 'true' } } } if (!enabledTargets.includes('x86_64-unknown-freebsd')) { jobsToRemove.push('build-freebsd') } // Filter other test jobs based on target for (const [jobName, jobConfig] of Object.entries(yaml.jobs || {})) { if ( jobName.startsWith('test-') && jobName !== 'test-macOS-windows-binding' && jobName !== 'test-linux-x64-gnu-binding' ) { // Extract target from job name or config const job = jobConfig as any if (job.strategy?.matrix?.settings?.[0]?.target) { const target = job.strategy.matrix.settings[0].target if (!enabledTargets.includes(target)) { jobsToRemove.push(jobName) } } } } // Remove jobs for disabled targets for (const jobName of jobsToRemove) { delete yaml.jobs[jobName] } if (Array.isArray(yaml.jobs?.publish?.needs)) { yaml.jobs.publish.needs = yaml.jobs.publish.needs.filter( (need: string) => !jobsToRemove.includes(need), ) } // Write back the filtered YAML const updatedYaml = yamlDump(yaml, { lineWidth: -1, noRefs: true, sortKeys: false, }) await fs.writeFile(filePath, updatedYaml) } function processOptions(options: RawNewOptions) { debug('Processing options...') const minNodeApiVersion = options.minNodeApiVersion ?? 4 if ( !Number.isInteger(minNodeApiVersion) || !SUPPORTED_NAPI_VERSIONS.some((version) => version === minNodeApiVersion) ) { throw new RangeError( `Unsupported Node-API version ${minNodeApiVersion}. Expected one of: ${SUPPORTED_NAPI_VERSIONS.join(', ')}`, ) } options.minNodeApiVersion = minNodeApiVersion if (!options.path) { throw new Error('Please provide the path as the argument') } options.path = path.resolve(process.cwd(), options.path) debug(`Resolved target path to: ${options.path}`) if (!options.name) { options.name = path.parse(options.path).base debug(`No project name provided, fix it to dir name: ${options.name}`) } if (!options.targets?.length) { if (options.enableAllTargets) { options.targets = AVAILABLE_TARGETS.concat() debug('Enable all targets') } else if (options.enableDefaultTargets) { options.targets = DEFAULT_TARGETS.concat() debug('Enable default targets') } else { throw new Error('At least one target must be enabled') } } const requestedTargets = options.targets.map((target) => ({ target, parsed: parseTriple(target), })) options.targets = requestedTargets.map(({ parsed }) => parsed.triple) const outputTargets = new Map<string, string>() for (const { target, parsed } of requestedTargets) { const { platformArchABI } = parsed const previous = outputTargets.get(platformArchABI) if (previous) { throw new Error( `Targets ${previous} and ${target} produce the same ${platformArchABI} artifact set. Choose one target spelling.`, ) } outputTargets.set(platformArchABI, target) } return applyDefaultNewOptions(options) as NewOptions } export async function newProject(userOptions: RawNewOptions) { debug('Will create napi-rs project with given options:') debug(userOptions) const options = processOptions(userOptions) debug('Targets to be enabled:') debug(options.targets) // Check if git is available if (!(await checkGitCommand())) { throw new Error( 'Git is not installed or not available in PATH. Please install Git to continue.', ) } const packageManager = options.packageManager as SupportedPackageManager // Ensure target directory exists and is empty await ensurePath(options.path, options.dryRun) if (!options.dryRun) { try { // Download or update template const cacheDir = await ensureCacheDir(packageManager) await downloadTemplate(packageManager, cacheDir) // Copy template files to target directory const templatePath = path.join(cacheDir, 'repo') await copyDirectory( templatePath, options.path, options.targets.some((target) => getWasiTarget(target) !== undefined), ) // Rename project using the rename API await renameProject({ cwd: options.path, name: options.name, binaryName: getBinaryName(options.name), }) const cargoTomlPath = path.join(options.path, 'Cargo.toml') if (existsSync(cargoTomlPath)) { await updateCargoTomlTypeDef(cargoTomlPath, options.enableTypeDef) await updateCargoTomlNodeApiVersion( cargoTomlPath, options.minNodeApiVersion, ) } // Filter targets in package.json const packageJsonPath = path.join(options.path, 'package.json') if (existsSync(packageJsonPath)) { await filterTargetsInPackageJson( packageJsonPath, options.targets, options.enableTypeDef, packageManager, ) } // Filter targets in GitHub Actions CI const ciPath = path.join(options.path, '.github', 'workflows', 'CI.yml') if (existsSync(ciPath) && options.enableGithubActions) { await filterTargetsInGithubActions(ciPath, options.targets) } else if ( !options.enableGithubActions && existsSync(path.join(options.path, '.github')) ) { // Remove .github directory if GitHub Actions is not enabled await fs.rm(path.join(options.path, '.github'), { recursive: true, force: true, }) } // Update package.json with additional configurations const pkgJsonContent = await fs.readFile(packageJsonPath, 'utf-8') const pkgJson = JSON.parse(pkgJsonContent) // Update engine requirement if (!pkgJson.engines) { pkgJson.engines = {} } const nodeEngineRequirement = napiEngineRequirement( options.minNodeApiVersion, ) pkgJson.engines.node = options.targets.every( (target) => parseTriple(target).platform === 'wasi', ) ? restrictWasiNodeEngine(nodeEngineRequirement) : nodeEngineRequirement // Update license if different from template if (options.license && pkgJson.license !== options.license) { pkgJson.license = options.license } // Update test framework if needed if (options.testFramework !== 'ava') { // This would require more complex logic to update test scripts and dependencies debug( `Test framework ${options.testFramework} requested but not yet implemented`, ) } await fs.writeFile( packageJsonPath, JSON.stringify(pkgJson, null, 2) + '\n', ) } catch (error) { throw new Error(`Failed to create project: ${error}`) } } debug(`Project created at: ${options.path}`) } async function ensurePath(path: string, dryRun = false) { const stat = await statAsync(path, {}).catch(() => undefined) // file descriptor exists if (stat) { if (stat.isFile()) { throw new Error( `Path ${path} for creating new napi-rs project already exists and it's not a directory.`, ) } else if (stat.isDirectory()) { const files = await readdirAsync(path) if (files.length) { throw new Error( `Path ${path} for creating new napi-rs project already exists and it's not empty.`, ) } } } if (!dryRun) { try { debug(`Try to create target directory: ${path}`) if (!dryRun) { await mkdirAsync(path, { recursive: true }) } } catch (e) { throw new Error(`Failed to create target directory: ${path}`, { cause: e, }) } } } function getBinaryName(name: string): string { return name.split('/').pop()! } export type { NewOptions }