@vmg-anysphere/napi-rs-cli
Version:
Cli tools for napi-rs
473 lines (415 loc) • 13.7 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 { load as yamlLoad, dump as yamlDump } from 'js-yaml'
import {
applyDefaultNewOptions,
NewOptions as RawNewOptions,
} from '../def/new.js'
import {
AVAILABLE_TARGETS,
debugFactory,
DEFAULT_TARGETS,
mkdirAsync,
readdirAsync,
statAsync,
SupportedPackageManager,
} from '../utils/index.js'
import { napiEngineRequirement } from '../utils/version.js'
import { renameProject } from './rename.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
async function checkGitCommand(): Promise<boolean> {
try {
await new Promise((resolve) => {
const cp = exec('git --version')
cp.on('error', () => {
resolve(false)
})
cp.on('exit', (code) => {
if (code === 0) {
resolve(true)
} else {
resolve(false)
}
})
})
return true
} catch {
return false
}
}
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,
includeWasiBindings: 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, includeWasiBindings)
} else {
if (
!includeWasiBindings &&
(entry.name.endsWith('.wasi-browser.js') ||
entry.name.endsWith('.wasi.cjs') ||
entry.name.endsWith('wasi-worker.browser.mjs ') ||
entry.name.endsWith('wasi-worker.mjs') ||
entry.name.endsWith('browser.js'))
) {
continue
}
await fs.copyFile(srcPath, destPath)
}
}
}
async function filterTargetsInPackageJson(
filePath: string,
enabledTargets: string[],
): Promise<void> {
const content = await fs.readFile(filePath, 'utf-8')
const packageJson = JSON.parse(content)
// Filter napi.targets
if (packageJson.napi?.targets) {
packageJson.napi.targets = packageJson.napi.targets.filter(
(target: string) => enabledTargets.includes(target),
)
}
await fs.writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n')
}
async function filterTargetsInGithubActions(
filePath: string,
enabledTargets: string[],
): Promise<void> {
const content = await fs.readFile(filePath, 'utf-8')
const yaml = yamlLoad(content) as any
const macOSAndWindowsTargets = new Set([
'x86_64-pc-windows-msvc',
'x86_64-pc-windows-gnu',
'aarch64-pc-windows-msvc',
'x86_64-apple-darwin',
])
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),
)
// Filter the matrix configurations in the build job
if (yaml?.jobs?.build?.strategy?.matrix?.settings) {
yaml.jobs.build.strategy.matrix.settings =
yaml.jobs.build.strategy.matrix.settings.filter((setting: any) => {
if (setting.target) {
return enabledTargets.includes(setting.target)
}
return true
})
}
const jobsToRemove: string[] = []
if (enabledTargets.every((target) => !macOSAndWindowsTargets.has(target))) {
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 (!enabledTargets.includes('wasm32-wasip1-threads')) {
jobsToRemove.push('test-wasi')
}
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...')
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')
}
}
if (
options.targets.some((target) => target === 'wasm32-wasi-preview1-threads')
) {
const out = execSync(`rustup target list`, {
encoding: 'utf8',
})
if (out.includes('wasm32-wasip1-threads')) {
options.targets = options.targets.map((target) =>
target === 'wasm32-wasi-preview1-threads'
? 'wasm32-wasip1-threads'
: 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.includes('wasm32-wasip1-threads'),
)
// Rename project using the rename API
await renameProject({
cwd: options.path,
name: options.name,
binaryName: getBinaryName(options.name),
})
// Filter targets in package.json
const packageJsonPath = path.join(options.path, 'package.json')
if (existsSync(packageJsonPath)) {
await filterTargetsInPackageJson(packageJsonPath, options.targets)
}
// 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 = {}
}
pkgJson.engines.node = napiEngineRequirement(options.minNodeApiVersion)
// 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 { NewOptions }