@napi-rs/cli
Version:
Cli tools for napi-rs
906 lines (817 loc) • 27.6 kB
text/typescript
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 }