@napi-rs/cli
Version:
Cli tools for napi-rs
1,677 lines (1,572 loc) • 47.6 kB
text/typescript
import { lstatSync, realpathSync } from 'node:fs'
import { builtinModules } from 'node:module'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path'
import { parse as parseToml, stringify as stringifyToml } from '@std/toml'
import { load as yamlParse, dump as yamlStringify } from 'js-yaml'
import { isNil, omitBy, pick } from 'es-toolkit'
import { applyDefaultRenameOptions, type RenameOptions } from '../def/rename.js'
import {
commitFileSystemTransaction,
readConfig,
readFileAsync,
resolvePackageReconciliationPaths,
type Target,
wasiLoaderSuffix,
wasiTargetHasThreads,
withPackageFileSystemReconciliation,
} from '../utils/index.js'
const WASI_ARTIFACT_METADATA_PREFIX = '// napi-rs-artifact-metadata:'
const SCOPED_PACKAGE_PATTERN = /^(?:@([^/]+?)\/)?([^/]+?)$/
const EXCLUDED_PACKAGE_NAMES = new Set(['node_modules', 'favicon.ico'])
const WINDOWS_RESERVED_FILENAME =
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
const PACKAGE_REFERENCE_FIELDS = new Set([
'browser',
'exports',
'files',
'imports',
'main',
'man',
'module',
'types',
'typesVersions',
'typings',
])
const DEPENDENCY_FIELDS = new Set([
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
'peerDependenciesMeta',
])
const RECURSIVE_PACKAGE_KEY_FIELDS = new Set(['overrides', 'resolutions'])
const PNPM_PACKAGE_ARRAY_FIELDS = new Set([
'ignoredBuiltDependencies',
'ignoredOptionalDependencies',
'minimumReleaseAgeExclude',
'neverBuiltDependencies',
'onlyBuiltDependencies',
'trustPolicyExclude',
])
type JsonRecord = Record<string, unknown>
interface ManagedFileRename {
source: string
destination: string
}
interface PlannedManagedFileRename extends ManagedFileRename {
replacesSameFile: boolean
}
interface PreparedWrite {
content: Buffer
destination: string
mode: number
removeBeforeWrite?: string
}
class RenameTransactionPlan {
readonly removals = new Set<string>()
readonly writes = new Map<string, PreparedWrite>()
addWrite(
destination: string,
content: Buffer | string,
mode: number,
removeBeforeWrite?: string,
) {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content)
const existing = this.writes.get(destination)
if (existing) {
if (
!existing.content.equals(buffer) ||
existing.mode !== mode ||
existing.removeBeforeWrite !== removeBeforeWrite
) {
throw new Error(
`Rename prepared conflicting updates for ${destination}`,
)
}
return
}
this.writes.set(destination, {
content: buffer,
destination,
mode,
removeBeforeWrite,
})
}
hasWrite(destination: string) {
return this.writes.has(destination)
}
}
function pathExists(path: string) {
try {
lstatSync(path)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
}
function assertRegularFile(path: string, description: string) {
const stats = lstatSync(path)
if (!stats.isFile()) {
throw new Error(`${description} must be a regular file: ${path}`)
}
return stats
}
function fileMode(path: string, description: string) {
return assertRegularFile(path, description).mode & 0o7777
}
function assertSafeBinaryName(name: unknown, description: string) {
const hasControlCharacter =
typeof name === 'string' &&
[...name].some((character) => character.charCodeAt(0) <= 0x1f)
if (
typeof name !== 'string' ||
name.length === 0 ||
name === '.' ||
name === '..' ||
name.trim() !== name ||
isAbsolute(name) ||
/[<>:"/\\|?*]/.test(name) ||
hasControlCharacter ||
name.endsWith('.') ||
WINDOWS_RESERVED_FILENAME.test(name)
) {
throw new Error(
`${description} must be a safe filename stem: ${JSON.stringify(name)}`,
)
}
}
function npmPackageNameIssues(name: unknown) {
if (typeof name !== 'string') {
return { errors: ['name must be a string'], warnings: [] }
}
const errors: string[] = []
const warnings: string[] = []
if (name.length === 0) {
errors.push('name length must be greater than zero')
}
if (name.length > 214) {
warnings.push('name can no longer contain more than 214 characters')
}
if (name.startsWith('.')) {
errors.push('name cannot start with a period')
}
if (name.startsWith('_')) {
errors.push('name cannot start with an underscore')
}
if (name.trim() !== name) {
errors.push('name cannot contain leading or trailing spaces')
}
if (name.toLowerCase() !== name) {
warnings.push('name can no longer contain capital letters')
}
if (EXCLUDED_PACKAGE_NAMES.has(name.toLowerCase())) {
errors.push(`${name} is not a valid package name`)
}
if (builtinModules.includes(name.toLowerCase())) {
warnings.push(`${name} is a core module name`)
}
const match = name.match(SCOPED_PACKAGE_PATTERN)
const scope = match?.[1]
const packageName = match?.[2]
if (packageName && /[~'!()*]/.test(packageName)) {
warnings.push('name can no longer contain special characters ("~\'!()*")')
}
if (
!packageName ||
packageName.startsWith('.') ||
(scope
? encodeURIComponent(scope) !== scope ||
encodeURIComponent(packageName) !== packageName
: encodeURIComponent(name) !== name)
) {
errors.push('name can only contain URL-friendly characters')
}
return {
errors: [...new Set(errors)],
warnings: [...new Set(warnings)],
}
}
function assertValidNpmPackageName(
name: unknown,
description: string,
strict: boolean,
): asserts name is string {
const { errors, warnings } = npmPackageNameIssues(name)
const issues = strict ? [...errors, ...warnings] : errors
if (issues.length > 0) {
throw new Error(
`${description} is not a valid npm package name: ${issues.join('; ')}`,
)
}
}
function validatePackageIdentity(
packageName: unknown,
targets: Target[],
description: string,
strict: boolean,
) {
assertValidNpmPackageName(packageName, description, strict)
for (const target of targets) {
assertValidNpmPackageName(
`${packageName}-${target.platformArchABI}`,
`${description} flavor for ${target.triple}`,
strict,
)
}
}
function createManagedWasiFiles(binaryName: string, targets: Target[]) {
const files = new Set<string>()
const add = (suffix: string) => files.add(`${binaryName}.${suffix}`)
const wasiTargets = targets.filter((target) => target.platform === 'wasi')
const flavors =
wasiTargets.length > 0
? wasiTargets.map((target) => ({
hasThreads: wasiTargetHasThreads(target),
platformArchABI: target.platformArchABI,
}))
: [{ hasThreads: true, platformArchABI: 'wasm32-wasi' }]
for (const flavor of flavors) {
const loaderSuffix = wasiLoaderSuffix(flavor.platformArchABI)
for (const suffix of [
`${flavor.platformArchABI}.wasm`,
`${flavor.platformArchABI}.debug.wasm`,
`${loaderSuffix}.cjs`,
`${loaderSuffix}.d.cts`,
`${loaderSuffix}-browser.js`,
]) {
add(suffix)
}
if (!flavor.hasThreads) {
for (const suffix of [
`${flavor.platformArchABI}.wasm.d.ts`,
`${flavor.platformArchABI}.wasm.d.mts`,
`${flavor.platformArchABI}.workerd.mjs`,
`${flavor.platformArchABI}.workerd.d.mts`,
`${loaderSuffix}-deferred.js`,
`${loaderSuffix}-deferred.d.ts`,
]) {
add(suffix)
}
}
}
add('wasm')
add('debug.wasm')
return files
}
function createManagedWasiRenames(
oldName: string,
newName: string,
targets: Target[],
) {
const renames = new Map<string, string>()
for (const oldFile of createManagedWasiFiles(oldName, targets)) {
renames.set(oldFile, `${newName}${oldFile.slice(oldName.length)}`)
}
return renames
}
function createManagedPackageRenames(
oldPackageName: string,
newPackageName: string,
targets: Target[],
) {
return new Map(
targets.map((target) => [
`${oldPackageName}-${target.platformArchABI}`,
`${newPackageName}-${target.platformArchABI}`,
]),
)
}
function replaceManifestReference(
value: string,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
const directFile = fileRenames.get(value)
if (directFile) {
return directFile
}
if (value.startsWith('./')) {
const relativeFile = fileRenames.get(value.slice(2))
if (relativeFile) {
return `./${relativeFile}`
}
}
return replacePackageSelectors(value, packageRenames, false)
}
function isPackageSelectorPrefix(
value: string,
index: number,
packageName: string,
allowSelectorPrefix: boolean,
) {
if (index === 0 || (index === 4 && value.startsWith('npm:'))) {
return true
}
if (!allowSelectorPrefix) {
return false
}
const previous = value[index - 1]
if (previous !== '/' && previous !== '>') {
return false
}
if (previous === '/' && !packageName.startsWith('@')) {
const previousSlash = value.lastIndexOf('/', index - 2)
const previousSegment = value
.slice(previousSlash + 1, index - 1)
.replace(/^npm:/, '')
if (previousSegment.startsWith('@')) {
return false
}
}
return true
}
function replacePackageSelectors(
value: string,
packageRenames: Map<string, string>,
allowSelectorPrefix: boolean,
) {
let cursor = 0
let updated = ''
const renames = [...packageRenames].sort(
([left], [right]) => right.length - left.length,
)
while (cursor < value.length) {
let match: { index: number; name: string; replacement: string } | undefined
for (const [name, replacement] of renames) {
let index = value.indexOf(name, cursor)
while (index !== -1) {
const next = value[index + name.length]
if (
isPackageSelectorPrefix(value, index, name, allowSelectorPrefix) &&
(next === undefined || next === '/' || next === '@')
) {
break
}
index = value.indexOf(name, index + name.length)
}
if (
index !== -1 &&
(!match ||
index < match.index ||
(index === match.index && name.length > match.name.length))
) {
match = { index, name, replacement }
}
}
if (!match) {
updated += value.slice(cursor)
break
}
updated += value.slice(cursor, match.index)
updated += match.replacement
cursor = match.index + match.name.length
}
return updated || value
}
function defineJsonProperty(target: JsonRecord, key: string, value: unknown) {
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
value,
writable: true,
})
}
function createJsonRecord() {
return Object.create(null) as JsonRecord
}
function rewriteReferenceTree(
value: unknown,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
rewriteKeys = true,
): unknown {
if (typeof value === 'string') {
return replaceManifestReference(value, fileRenames, packageRenames)
}
if (Array.isArray(value)) {
return value.map((entry) =>
rewriteReferenceTree(entry, fileRenames, packageRenames, rewriteKeys),
)
}
if (typeof value !== 'object' || value === null) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
const updatedKey = rewriteKeys
? replaceManifestReference(key, fileRenames, packageRenames)
: key
if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
throw new Error(
`Renaming managed manifest reference ${key} conflicts with existing key ${updatedKey}`,
)
}
defineJsonProperty(
updated,
updatedKey,
rewriteReferenceTree(entry, fileRenames, packageRenames, rewriteKeys),
)
}
return updated
}
function rewritePackageMap(
value: unknown,
packageRenames: Map<string, string>,
recursive: boolean,
): unknown {
if (Array.isArray(value)) {
return rewritePackageSelectorArray(value, packageRenames)
}
if (typeof value !== 'object' || value === null) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
const updatedKey = replacePackageSelectors(key, packageRenames, true)
if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
throw new Error(
`Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
)
}
const updatedEntry =
typeof entry === 'string'
? replacePackageDescriptor(entry, packageRenames)
: recursive
? rewritePackageMap(entry, packageRenames, true)
: entry
defineJsonProperty(updated, updatedKey, updatedEntry)
}
return updated
}
function replacePackageDescriptor(
value: string,
packageRenames: Map<string, string>,
) {
if (value.startsWith('npm:')) {
return replacePackageSelectors(value, packageRenames, false)
}
if (!value.startsWith('patch:')) {
return value
}
const source = value.slice('patch:'.length)
const packageSeparator = source.startsWith('@')
? source.indexOf('@', source.indexOf('/') + 1)
: source.indexOf('@')
if (packageSeparator <= 0) {
return value
}
const packageName = source.slice(0, packageSeparator)
const replacement = packageRenames.get(packageName)
return replacement
? `patch:${replacement}${source.slice(packageSeparator)}`
: value
}
function rewritePackageSelectorArray(
value: unknown,
packageRenames: Map<string, string>,
) {
if (!Array.isArray(value)) {
return value
}
return value.map((entry) =>
typeof entry === 'string'
? replacePackageSelectors(entry, packageRenames, true)
: entry,
)
}
function rewritePackageSelectorKeys(
value: unknown,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
const updatedKey = replacePackageSelectors(key, packageRenames, true)
if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
throw new Error(
`Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
)
}
defineJsonProperty(updated, updatedKey, entry)
}
return updated
}
function rewritePackageExtensions(
value: unknown,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
const updatedKey = replacePackageSelectors(key, packageRenames, true)
if (Object.prototype.hasOwnProperty.call(updated, updatedKey)) {
throw new Error(
`Renaming package reference ${key} conflicts with existing key ${updatedKey}`,
)
}
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
defineJsonProperty(updated, updatedKey, entry)
continue
}
const updatedExtension = createJsonRecord()
for (const [field, fieldValue] of Object.entries(entry)) {
defineJsonProperty(
updatedExtension,
field,
DEPENDENCY_FIELDS.has(field)
? rewritePackageMap(fieldValue, packageRenames, false)
: fieldValue,
)
}
defineJsonProperty(updated, updatedKey, updatedExtension)
}
return updated
}
function rewritePnpmPeerDependencyRules(
value: unknown,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
defineJsonProperty(
updated,
key,
key === 'ignoreMissing' || key === 'allowAny'
? rewritePackageSelectorArray(entry, packageRenames)
: key === 'allowedVersions'
? rewritePackageSelectorKeys(entry, packageRenames)
: entry,
)
}
return updated
}
function rewritePnpmUpdateConfig(
value: unknown,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
defineJsonProperty(
updated,
key,
key === 'ignoreDependencies'
? rewritePackageSelectorArray(entry, packageRenames)
: entry,
)
}
return updated
}
function rewritePnpmConfig(
value: unknown,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
let rewritten = entry
if (key === 'overrides') {
rewritten = rewritePackageMap(entry, packageRenames, true)
} else if (key === 'patchedDependencies') {
rewritten = rewritePackageSelectorKeys(entry, packageRenames)
} else if (key === 'packageExtensions') {
rewritten = rewritePackageExtensions(entry, packageRenames)
} else if (key === 'allowedDeprecatedVersions') {
rewritten = rewritePackageSelectorKeys(entry, packageRenames)
} else if (key === 'allowBuilds') {
rewritten = rewritePackageSelectorKeys(entry, packageRenames)
} else if (key === 'peerDependencyRules') {
rewritten = rewritePnpmPeerDependencyRules(entry, packageRenames)
} else if (key === 'updateConfig') {
rewritten = rewritePnpmUpdateConfig(entry, packageRenames)
} else if (PNPM_PACKAGE_ARRAY_FIELDS.has(key)) {
rewritten = rewritePackageSelectorArray(entry, packageRenames)
}
defineJsonProperty(updated, key, rewritten)
}
return updated
}
function replaceScriptArgument(
argument: string,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
const direct = replaceManifestReference(argument, fileRenames, packageRenames)
if (direct !== argument) {
return direct
}
const assignment = argument.match(
/^([A-Za-z_][A-Za-z0-9_]*|--?[A-Za-z0-9][A-Za-z0-9-]*)=(.+)$/,
)
if (!assignment) {
return argument
}
const replacement = replaceManifestReference(
assignment[2],
fileRenames,
packageRenames,
)
return replacement === assignment[2]
? argument
: `${assignment[1]}=${replacement}`
}
function rewriteScriptCommand(
command: string,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
let index = 0
let updated = ''
while (index < command.length) {
const character = command[index]
if (/\s|[|&;()<>]/.test(character)) {
updated += character
index += 1
continue
}
if (character === '"' || character === "'") {
const quote = character
let end = index + 1
while (end < command.length) {
if (command[end] === quote) {
break
}
if (
quote === '"' &&
command[end] === '\\' &&
end + 1 < command.length
) {
end += 2
} else {
end += 1
}
}
if (end >= command.length) {
updated += command.slice(index)
break
}
const argument = command.slice(index + 1, end)
updated += `${quote}${replaceScriptArgument(
argument,
fileRenames,
packageRenames,
)}${quote}`
index = end + 1
continue
}
let end = index + 1
while (end < command.length && !/\s|[|&;()<>"']/.test(command[end])) {
end += 1
}
updated += replaceScriptArgument(
command.slice(index, end),
fileRenames,
packageRenames,
)
index = end
}
return updated
}
function rewriteScripts(
value: unknown,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
defineJsonProperty(
updated,
key,
typeof entry === 'string'
? rewriteScriptCommand(entry, fileRenames, packageRenames)
: entry,
)
}
return updated
}
function rewritePublishConfig(
value: unknown,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(value)) {
defineJsonProperty(
updated,
key,
PACKAGE_REFERENCE_FIELDS.has(key)
? rewriteReferenceTree(entry, fileRenames, packageRenames)
: entry,
)
}
return updated
}
function rewritePackageManifest(
manifest: JsonRecord,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
const updated = createJsonRecord()
for (const [key, entry] of Object.entries(manifest)) {
let rewritten = entry
if (key === 'name' && typeof entry === 'string') {
rewritten = replaceManifestReference(entry, new Map(), packageRenames)
} else if (PACKAGE_REFERENCE_FIELDS.has(key)) {
rewritten = rewriteReferenceTree(entry, fileRenames, packageRenames)
} else if (key === 'bin') {
rewritten = rewriteReferenceTree(
entry,
fileRenames,
packageRenames,
false,
)
} else if (DEPENDENCY_FIELDS.has(key)) {
rewritten = rewritePackageMap(entry, packageRenames, false)
} else if (key === 'dependenciesMeta') {
rewritten = rewritePackageSelectorKeys(entry, packageRenames)
} else if (key === 'trustedDependencies') {
rewritten = rewritePackageSelectorArray(entry, packageRenames)
} else if (key === 'bundledDependencies' || key === 'bundleDependencies') {
rewritten = rewritePackageMap(entry, packageRenames, false)
} else if (RECURSIVE_PACKAGE_KEY_FIELDS.has(key)) {
rewritten = rewritePackageMap(entry, packageRenames, true)
} else if (key === 'pnpm') {
rewritten = rewritePnpmConfig(entry, packageRenames)
} else if (key === 'scripts') {
rewritten = rewriteScripts(entry, fileRenames, packageRenames)
} else if (key === 'publishConfig') {
rewritten = rewritePublishConfig(entry, fileRenames, packageRenames)
}
defineJsonProperty(updated, key, rewritten)
}
return updated
}
function replaceManagedTextReferences(
content: string,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
const references = new Map<
string,
{ replacement: string; packageName: boolean }
>()
for (const [name, replacement] of fileRenames) {
references.set(name, { replacement, packageName: false })
}
for (const [name, replacement] of packageRenames) {
references.set(name, { replacement, packageName: true })
}
if (references.size === 0) {
return content
}
const pattern = new RegExp(
[...references.keys()]
.sort((left, right) => right.length - left.length)
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
'g',
)
return content.replace(pattern, (name: string, offset: number) => {
const reference = references.get(name)!
const previous = content[offset - 1]
const next = content[offset + name.length]
if (reference.packageName) {
if (previous && /[A-Za-z0-9._~@/-]/.test(previous)) {
return name
}
if (next && next !== '/' && /[A-Za-z0-9._~-]/.test(next)) {
return name
}
} else {
if (previous && /[A-Za-z0-9._-]/.test(previous)) {
return name
}
if (next && /[A-Za-z0-9._-]/.test(next)) {
return name
}
}
return reference.replacement
})
}
function isPathWithin(root: string, path: string, allowRoot: boolean) {
const relativePath = relative(root, path)
return (
(allowRoot && relativePath === '') ||
(relativePath !== '' &&
relativePath !== '..' &&
!relativePath.startsWith(`..${sep}`) &&
!isAbsolute(relativePath))
)
}
function assertPathWithin(
root: string,
path: string,
description: string,
allowRoot = false,
) {
if (!isPathWithin(root, path, allowRoot)) {
throw new Error(`${description} escapes project root ${root}: ${path}`)
}
}
function assertRenamePathIdentity(
expected: string,
actual: string,
description: string,
) {
if (relative(expected, actual) === '' && relative(actual, expected) === '') {
return
}
const error = new Error(
`${description} changed after package reconciliation paths were resolved: ${expected} -> ${actual}`,
) as NodeJS.ErrnoException
error.code = 'ESTALE'
error.path = actual
throw error
}
function revalidateRenameReconciliationPaths(
options: ReturnType<typeof applyDefaultRenameOptions>,
expected: ReturnType<typeof resolvePackageReconciliationPaths>,
) {
let actual: ReturnType<typeof resolvePackageReconciliationPaths>
try {
actual = resolvePackageReconciliationPaths(
options.cwd,
options.packageJsonPath,
[options.npmDir],
)
} catch (cause) {
const error = new Error(
'Package reconciliation paths changed after their locks were acquired',
{ cause },
) as NodeJS.ErrnoException
error.code = 'ESTALE'
error.path = expected.boundary
throw error
}
assertRenamePathIdentity(
expected.boundary,
actual.boundary,
'Package reconciliation boundary',
)
assertRenamePathIdentity(expected.cwd, actual.cwd, 'Project root')
assertRenamePathIdentity(
expected.packageRoot,
actual.packageRoot,
'Package root',
)
assertRenamePathIdentity(
expected.packageJsonPath,
actual.packageJsonPath,
'Package manifest',
)
if (expected.managedPaths.length !== actual.managedPaths.length) {
const error = new Error(
'Managed package paths changed after their locks were acquired',
) as NodeJS.ErrnoException
error.code = 'ESTALE'
error.path = actual.cwd
throw error
}
for (let index = 0; index < expected.managedPaths.length; index++) {
assertRenamePathIdentity(
expected.managedPaths[index],
actual.managedPaths[index],
`Managed package path ${index + 1}`,
)
}
return actual
}
async function canonicalizePath(path: string) {
let current = resolve(path)
const missingSegments: string[] = []
while (true) {
try {
return join(await realpath(current), ...missingSegments)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
const parent = dirname(current)
if (parent === current) {
return join(current, ...missingSegments)
}
missingSegments.unshift(basename(current))
current = parent
}
}
}
async function resolveCanonicalRoot(
projectRoot: string,
path: string,
description: string,
) {
const lexicalPath = resolve(path)
assertPathWithin(projectRoot, lexicalPath, description, true)
const canonicalPath = await canonicalizePath(lexicalPath)
assertPathWithin(projectRoot, canonicalPath, description, true)
return canonicalPath
}
async function resolveCanonicalFile(
projectRoot: string,
path: string,
description: string,
) {
const lexicalPath = resolve(path)
assertPathWithin(projectRoot, lexicalPath, description)
const canonicalParent = await canonicalizePath(dirname(lexicalPath))
assertPathWithin(projectRoot, canonicalParent, `${description} parent`, true)
const canonicalPath = join(canonicalParent, basename(lexicalPath))
assertPathWithin(projectRoot, canonicalPath, description)
if (pathExists(canonicalPath)) {
assertRegularFile(canonicalPath, description)
}
return canonicalPath
}
function resolveManagedPath(
directory: string,
entry: string,
description: string,
) {
if (!entry || isAbsolute(entry)) {
throw new Error(
`${description} must be a non-empty relative path: ${entry}`,
)
}
const root = resolve(directory)
const path = resolve(root, entry)
assertPathWithin(root, path, description)
return path
}
async function resolveProjectEntry(
projectRoot: string,
entry: string,
description: string,
) {
const path = resolve(projectRoot, entry)
if (!isPathWithin(projectRoot, path, false)) {
return
}
return resolveCanonicalFile(projectRoot, path, description)
}
function collectManagedFileRenames(
directory: string,
renames: Map<string, string>,
) {
const operations: ManagedFileRename[] = []
for (const [oldFile, newFile] of renames) {
const source = resolveManagedPath(
directory,
oldFile,
'Managed rename source',
)
const destination = resolveManagedPath(
directory,
newFile,
'Managed rename destination',
)
if (source !== destination && pathExists(source)) {
assertRegularFile(source, 'Managed rename source')
operations.push({ source, destination })
}
}
return operations
}
function pathsReferToSameFile(left: string, right: string) {
try {
const leftStats = lstatSync(left)
const rightStats = lstatSync(right)
if (!leftStats.isFile() || !rightStats.isFile()) {
return false
}
const leftPath = realpathSync.native(left)
const rightPath = realpathSync.native(right)
if (
leftPath === rightPath ||
(process.platform === 'win32' &&
leftPath.toLowerCase() === rightPath.toLowerCase())
) {
return true
}
return (
(process.platform === 'darwin' || process.platform === 'win32') &&
resolve(left).toLowerCase() === resolve(right).toLowerCase() &&
leftStats.dev === rightStats.dev &&
leftStats.ino === rightStats.ino
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
}
function preflightManagedFileRenames(operations: ManagedFileRename[]) {
const operationsBySource = new Map<string, ManagedFileRename>()
const sources = new Set<string>()
const destinations = new Map<string, string>()
for (const operation of operations) {
const existingSource = operationsBySource.get(operation.source)
if (existingSource) {
if (existingSource.destination !== operation.destination) {
throw new Error(
`Managed rename source ${operation.source} has conflicting destinations ${existingSource.destination} and ${operation.destination}`,
)
}
continue
}
operationsBySource.set(operation.source, operation)
sources.add(operation.source)
const existingDestination = destinations.get(operation.destination)
if (existingDestination) {
throw new Error(
`Managed rename destination ${operation.destination} is targeted by both ${existingDestination} and ${operation.source}`,
)
}
destinations.set(operation.destination, operation.source)
}
const planned: PlannedManagedFileRename[] = []
for (const operation of operationsBySource.values()) {
const replacesSameFile =
pathExists(operation.destination) &&
pathsReferToSameFile(operation.source, operation.destination)
if (
pathExists(operation.destination) &&
!sources.has(operation.destination) &&
!replacesSameFile
) {
throw new Error(
`Cannot rename managed file ${operation.source}: destination already exists: ${operation.destination}`,
)
}
planned.push({ ...operation, replacesSameFile })
}
return planned
}
async function prepareManagedFileRenames(
plan: RenameTransactionPlan,
operations: PlannedManagedFileRename[],
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
const destinations = new Set(
operations.map((operation) => operation.destination),
)
for (const operation of operations) {
const mode = fileMode(operation.source, 'Managed rename source')
const content = await readFileAsync(operation.source)
plan.addWrite(
operation.destination,
operation.source.endsWith('.wasm')
? content
: replaceManagedTextReferences(
content.toString('utf8'),
fileRenames,
packageRenames,
),
mode,
operation.replacesSameFile ? operation.source : undefined,
)
if (!operation.replacesSameFile && !destinations.has(operation.source)) {
plan.removals.add(operation.source)
}
}
}
async function prepareManagedTextWrite(
plan: RenameTransactionPlan,
path: string,
fileRenames: Map<string, string>,
packageRenames: Map<string, string>,
) {
if (plan.hasWrite(path) || !pathExists(path) || path.endsWith('.wasm')) {
return
}
const mode = fileMode(path, 'Managed text file')
const content = await readFileAsync(path, 'utf8')
const updated = replaceManagedTextReferences(
content,
fileRenames,
packageRenames,
)
if (updated !== content) {
plan.addWrite(path, updated, mode)
}
}
function managedPackageReadme(packageName: string, target: Target) {
return `# \`${packageName}-${target.platformArchABI}\`
This is the **${target.triple}** binary for \`${packageName}\`
`
}
async function prepareManagedPackageReadme(
plan: RenameTransactionPlan,
directory: string,
oldPackageName: string,
newPackageName: string,
target: Target,
) {
const path = resolveManagedPath(
directory,
'README.md',
'Managed package README',
)
if (!pathExists(path)) {
return
}
const mode = fileMode(path, 'Managed package README')
const content = await readFileAsync(path, 'utf8')
if (content === managedPackageReadme(oldPackageName, target)) {
plan.addWrite(path, managedPackageReadme(newPackageName, target), mode)
}
}
function managedRootEntries(content: string) {
const firstLine = content.split(/\r?\n/, 1)[0]
if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) {
return []
}
try {
const metadata = JSON.parse(
firstLine.slice(WASI_ARTIFACT_METADATA_PREFIX.length),
) as {
rootEntry?: unknown
managedRootEntries?: unknown
}
return [
...(typeof metadata.rootEntry === 'string' ? [metadata.rootEntry] : []),
...(Array.isArray(metadata.managedRootEntries)
? metadata.managedRootEntries.filter(
(entry): entry is string => typeof entry === 'string',
)
: []),
]
} catch {
return []
}
}
function asJsonRecord(value: unknown): JsonRecord | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as JsonRecord)
: undefined
}
function updateNapiConfigRecord(record: JsonRecord, options: RenameOptions) {
if (options.binaryName !== undefined) {
defineJsonProperty(record, 'binaryName', options.binaryName)
delete record.name
}
if (options.packageName !== undefined) {
defineJsonProperty(record, 'packageName', options.packageName)
}
}
function serializeJsonLike(content: string, value: unknown) {
return `${JSON.stringify(value, null, 2)}${content.endsWith('\n') ? '\n' : ''}`
}
function sanitizeCargoPackageName(binaryName: string) {
return binaryName.replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()
}
async function executeRenameTransaction(
projectRoot: string,
plan: RenameTransactionPlan,
) {
if (plan.writes.size === 0 && plan.removals.size === 0) {
return
}
const stagingRoot = await mkdtemp(join(tmpdir(), 'napi-rename-stage-'))
try {
const writes = []
let sequence = 0
for (const prepared of plan.writes.values()) {
const source = join(stagingRoot, String(sequence++))
await writeFile(source, prepared.content, { mode: prepared.mode })
writes.push({
destination: prepared.destination,
mode: prepared.mode,
removeBeforeWrite: prepared.removeBeforeWrite,
source,
})
}
await commitFileSystemTransaction(projectRoot, writes, [...plan.removals])
} finally {
await rm(stagingRoot, { force: true, recursive: true })
}
}
async function renameProjectUnlocked(
userOptions: RenameOptions,
resolvedPaths: ReturnType<typeof resolvePackageReconciliationPaths>,
) {
const options = applyDefaultRenameOptions(userOptions)
const lockedPaths = revalidateRenameReconciliationPaths(
options,
resolvedPaths,
)
const projectRoot = await realpath(resolve(options.cwd))
assertRenamePathIdentity(lockedPaths.cwd, projectRoot, 'Project root')
const requestedPackageJsonPath = await resolveCanonicalFile(
projectRoot,
resolve(projectRoot, options.packageJsonPath),
'package.json',
)
const packageJsonPath = await realpath(requestedPackageJsonPath)
assertRenamePathIdentity(
lockedPaths.packageJsonPath,
packageJsonPath,
'Package manifest',
)
const configPath = options.configPath
? await resolveCanonicalFile(
projectRoot,
resolve(projectRoot, options.configPath),
'NAPI config',
)
: undefined
const npmRoot = await resolveCanonicalRoot(
projectRoot,
resolve(projectRoot, options.npmDir),
'npm package root',
)
assertRenamePathIdentity(
lockedPaths.managedPaths[0],
npmRoot,
'npm package root',
)
const normalizedOptions = {
...options,
cwd: projectRoot,
packageJsonPath,
configPath,
npmDir: npmRoot,
}
const napiConfig = await readConfig(normalizedOptions)
const oldName = napiConfig.binaryName
const oldPackageName = napiConfig.packageName
assertSafeBinaryName(oldName, 'Configured binary name')
validatePackageIdentity(
oldPackageName,
napiConfig.targets,
'Configured package name',
false,
)
if (options.binaryName !== undefined) {
assertSafeBinaryName(options.binaryName, 'Requested binary name')
}
if (options.name !== undefined) {
assertValidNpmPackageName(options.name, 'Requested root package name', true)
}
if (options.packageName !== undefined) {
validatePackageIdentity(
options.packageName,
napiConfig.targets,
'Requested package name',
true,
)
}
const newName = options.binaryName ?? oldName
const newPackageName = options.packageName ?? oldPackageName
const binaryNameChanged =
options.binaryName !== undefined && oldName !== options.binaryName
const packageNameChanged =
options.packageName !== undefined && oldPackageName !== options.packageName
const managedWasiRenames = binaryNameChanged
? createManagedWasiRenames(oldName, newName, napiConfig.targets)
: new Map<string, string>()
const managedPackageRenames = packageNameChanged
? createManagedPackageRenames(
oldPackageName,
newPackageName,
napiConfig.targets,
)
: new Map<string, string>()
const hasManagedReferenceRenames =
managedWasiRenames.size > 0 || managedPackageRenames.size > 0
const packageJsonMode = fileMode(packageJsonPath, 'package.json')
const packageJsonContent = await readFileAsync(packageJsonPath, 'utf8')
const parsedPackageJson = JSON.parse(packageJsonContent)
const packageJsonData = asJsonRecord(parsedPackageJson)
if (!packageJsonData) {
throw new Error(
`package.json must contain a JSON object: ${packageJsonPath}`,
)
}
const managedRootEntryNames = new Set<string>()
if (hasManagedReferenceRenames) {
for (const field of ['main', 'module', 'browser', 'types'] as const) {
const entry = packageJsonData[field]
if (typeof entry === 'string') {
managedRootEntryNames.add(entry)
}
}
for (const oldFile of createManagedWasiFiles(oldName, napiConfig.targets)) {
if (!oldFile.endsWith('.cjs')) {
continue
}
const loaderPath = resolveManagedPath(
projectRoot,
oldFile,
'Managed WASI loader',
)
if (pathExists(loaderPath)) {
assertRegularFile(loaderPath, 'Managed WASI loader')
for (const entry of managedRootEntries(
await readFileAsync(loaderPath, 'utf8'),
)) {
managedRootEntryNames.add(entry)
}
}
}
}
for (const [key, value] of Object.entries(
omitBy(
// @ts-expect-error missing fields: author and license
pick(options, ['name', 'description', 'author', 'license']),
isNil,
),
)) {
defineJsonProperty(packageJsonData, key, value)
}
if (options.binaryName !== undefined || options.packageName !== undefined) {
const napi = asJsonRecord(packageJsonData.napi) ?? createJsonRecord()
updateNapiConfigRecord(napi, options)
defineJsonProperty(packageJsonData, 'napi', napi)
}
if (options.repository) {
const repository = asJsonRecord(packageJsonData.repository)
if (repository) {
defineJsonProperty(repository, 'url', options.repository)
} else {
defineJsonProperty(packageJsonData, 'repository', options.repository)
}
}
const plan = new RenameTransactionPlan()
plan.addWrite(
packageJsonPath,
serializeJsonLike(
packageJsonContent,
rewritePackageManifest(
packageJsonData,
managedWasiRenames,
managedPackageRenames,
),
),
packageJsonMode,
)
if (configPath) {
const configMode = fileMode(configPath, 'NAPI config')
const configContent = await readFileAsync(configPath, 'utf8')
const configData = asJsonRecord(JSON.parse(configContent))
if (!configData) {
throw new Error(`NAPI config must contain a JSON object: ${configPath}`)
}
updateNapiConfigRecord(configData, options)
plan.addWrite(
configPath,
serializeJsonLike(configContent, configData),
configMode,
)
}
if (binaryNameChanged) {
const cargoTomlPath = await resolveCanonicalFile(
projectRoot,
resolve(projectRoot, options.manifestPath),
'Cargo manifest',
)
const cargoTomlMode = fileMode(cargoTomlPath, 'Cargo manifest')
const tomlContent = await readFileAsync(cargoTomlPath, 'utf8')
const cargoToml = parseToml(tomlContent) as any
if (cargoToml.package) {
cargoToml.package.name = sanitizeCargoPackageName(newName)
plan.addWrite(cargoTomlPath, stringifyToml(cargoToml), cargoTomlMode)
}
const workflowPath = join(projectRoot, '.github', 'workflows', 'CI.yml')
if (pathExists(workflowPath)) {
const canonicalWorkflowPath = await resolveCanonicalFile(
projectRoot,
workflowPath,
'GitHub Actions workflow',
)
const workflowMode = fileMode(
canonicalWorkflowPath,
'GitHub Actions workflow',
)
const workflowContent = await readFileAsync(canonicalWorkflowPath, 'utf8')
const workflowData = yamlParse(workflowContent) as any
if (workflowData.env?.APP_NAME) {
workflowData.env.APP_NAME = newName
plan.addWrite(
canonicalWorkflowPath,
yamlStringify(workflowData, {
lineWidth: -1,
noRefs: true,
sortKeys: false,
}),
workflowMode,
)
}
}
}
const targetDirectories: Array<{ directory: string; target: Target }> = []
for (const target of napiConfig.targets) {
const directory = await resolveCanonicalRoot(
projectRoot,
resolve(npmRoot, target.platformArchABI),
`npm package directory for ${target.triple}`,
)
targetDirectories.push({ directory, target })
}
const managedFileRenames = collectManagedFileRenames(
projectRoot,
managedWasiRenames,
)
for (const { target, directory } of targetDirectories) {
if (!pathExists(directory)) {
continue
}
if (target.platform === 'wasi') {
managedFileRenames.push(
...collectManagedFileRenames(directory, managedWasiRenames),
)
}
if (hasManagedReferenceRenames) {
const targetPackageJsonPath = resolveManagedPath(
directory,
'package.json',
'Managed package manifest',
)
if (pathExists(targetPackageJsonPath)) {
const manifestMode = fileMode(
targetPackageJsonPath,
'Managed package manifest',
)
const content = await readFileAsync(targetPackageJsonPath, 'utf8')
const manifest = asJsonRecord(JSON.parse(content))
if (!manifest) {
throw new Error(
`Managed package manifest must contain a JSON object: ${targetPackageJsonPath}`,
)
}
const updatedContent = serializeJsonLike(
content,
rewritePackageManifest(
manifest,
managedWasiRenames,
managedPackageRenames,
),
)
if (updatedContent !== content) {
plan.addWrite(targetPackageJsonPath, updatedContent, manifestMode)
}
}
}
}
const plannedFileRenames = preflightManagedFileRenames(managedFileRenames)
await prepareManagedFileRenames(
plan,
plannedFileRenames,
managedWasiRenames,
managedPackageRenames,
)
if (hasManagedReferenceRenames) {
for (const file of createManagedWasiFiles(newName, napiConfig.targets)) {
await prepareManagedTextWrite(
plan,
resolveManagedPath(projectRoot, file, 'Managed WASI file'),
managedWasiRenames,
managedPackageRenames,
)
}
for (const { target, directory } of targetDirectories) {
if (!pathExists(directory)) {
continue
}
if (target.platform === 'wasi') {
for (const file of createManagedWasiFiles(newName, [target])) {
await prepareManagedTextWrite(
plan,
resolveManagedPath(directory, file, 'Managed package WASI file'),
managedWasiRenames,
managedPackageRenames,
)
}
}
if (managedPackageRenames.size > 0) {
await prepareManagedPackageReadme(
plan,
directory,
oldPackageName,
newPackageName,
target,
)
}
}
for (const entry of managedRootEntryNames) {
const updatedEntry = replaceManifestReference(
entry,
managedWasiRenames,
new Map(),
)
const path = await resolveProjectEntry(
projectRoot,
updatedEntry,
'Managed root entry',
)
if (path) {
await prepareManagedTextWrite(
plan,
path,
managedWasiRenames,
managedPackageRenames,
)
}
}
if (managedWasiRenames.size > 0) {
await prepareManagedTextWrite(
plan,
resolveManagedPath(
projectRoot,
'.gitattributes',
'Managed .gitattributes file',
),
managedWasiRenames,
new Map(),
)
}
}
await executeRenameTransaction(projectRoot, plan)
}
export async function renameProject(userOptions: RenameOptions) {
const options = applyDefaultRenameOptions(userOptions)
const resolvedPaths = resolvePackageReconciliationPaths(
options.cwd,
options.packageJsonPath,
[options.npmDir],
)
return withPackageFileSystemReconciliation(resolvedPaths, () =>
renameProjectUnlocked(options, resolvedPaths),
)
}