@napi-rs/cli 3.7.2 → 3.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cli.js +240 -64
  2. package/dist/index.cjs +239 -63
  3. package/dist/index.d.cts +5 -5
  4. package/dist/index.d.ts +5 -5
  5. package/dist/index.js +240 -64
  6. package/package.json +3 -3
  7. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +353 -0
  8. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  9. package/src/api/__tests__/android-build.spec.ts +113 -0
  10. package/src/api/__tests__/artifacts.spec.ts +95 -0
  11. package/src/api/__tests__/build-regressions.spec.ts +128 -0
  12. package/src/api/__tests__/build.spec.ts +1020 -0
  13. package/src/api/__tests__/create-npm-dirs.spec.ts +561 -0
  14. package/src/api/__tests__/new-node-api-version.spec.ts +87 -0
  15. package/src/api/__tests__/new.spec.ts +527 -0
  16. package/src/api/__tests__/rename.spec.ts +181 -0
  17. package/src/api/__tests__/templates.spec.ts +164 -0
  18. package/src/api/__tests__/universalize.spec.ts +128 -0
  19. package/src/api/artifacts.ts +7 -16
  20. package/src/api/build.ts +354 -111
  21. package/src/api/new.ts +65 -1
  22. package/src/commands/__tests__/new.spec.ts +13 -0
  23. package/src/def/build.ts +6 -6
  24. package/src/utils/__tests__/__fixtures__/napi_type_def +202 -0
  25. package/src/utils/__tests__/__fixtures__/optional-napi-derive/Cargo.toml +3 -0
  26. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/Cargo.toml +8 -0
  27. package/src/utils/__tests__/__fixtures__/optional-napi-derive/main-crate/src/lib.rs +1 -0
  28. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/Cargo.toml +5 -0
  29. package/src/utils/__tests__/__fixtures__/optional-napi-derive/napi-derive/src/lib.rs +1 -0
  30. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/Cargo.toml +11 -0
  31. package/src/utils/__tests__/__fixtures__/optional-napi-derive/with-optional-derive/src/lib.rs +1 -0
  32. package/src/utils/__tests__/__fixtures__/runtime_string_enum_flag +2 -0
  33. package/src/utils/__tests__/__snapshots__/config.spec.ts.md +97 -0
  34. package/src/utils/__tests__/__snapshots__/config.spec.ts.snap +0 -0
  35. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +180 -0
  36. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  37. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +1197 -0
  38. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  39. package/src/utils/__tests__/__snapshots__/version.spec.ts.md +22 -0
  40. package/src/utils/__tests__/__snapshots__/version.spec.ts.snap +0 -0
  41. package/src/utils/__tests__/config.spec.ts +81 -0
  42. package/src/utils/__tests__/metadata.spec.ts +69 -0
  43. package/src/utils/__tests__/misc.spec.ts +56 -0
  44. package/src/utils/__tests__/target.spec.ts +19 -0
  45. package/src/utils/__tests__/typegen.spec.ts +85 -0
  46. package/src/utils/__tests__/version.spec.ts +7 -0
  47. package/src/utils/metadata.ts +94 -3
  48. package/src/utils/version.ts +2 -0
package/src/api/build.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
- import { existsSync, mkdirSync, rmSync } from 'node:fs'
3
+ import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
4
4
  import { createRequire } from 'node:module'
5
5
  import { homedir } from 'node:os'
6
- import { parse, join, resolve } from 'node:path'
6
+ import { basename, parse, join, resolve } from 'node:path'
7
7
 
8
8
  import * as colors from 'colorette'
9
9
 
@@ -16,6 +16,7 @@ import {
16
16
  DEFAULT_TYPE_DEF_HEADER,
17
17
  fileExists,
18
18
  getSystemDefaultTarget,
19
+ getNapiDeriveDependentCrates,
19
20
  getTargetLinker,
20
21
  mkdirAsync,
21
22
  type NapiConfig,
@@ -62,10 +63,28 @@ export async function buildProject(rawOptions: BuildOptions) {
62
63
  cwd: rawOptions.cwd ?? process.cwd(),
63
64
  }
64
65
 
66
+ // Reject invalid cross-compilation flag combinations before anything with
67
+ // a side effect runs (`cargo metadata`, cargo binary auto-installs,
68
+ // toolchain downloads, ...), so the user always gets the validation error
69
+ // rather than an unrelated failure from those steps.
70
+ validateCrossCompileFlags(options)
71
+ if (options.useNapiCross) {
72
+ // Check the host constraint before resolving the target: without an
73
+ // explicit `--target` (or `CARGO_BUILD_TARGET`) the target resolution
74
+ // spawns `rustc -vV`, and on an unsupported host without Rust on the
75
+ // `PATH` that spawn failure would mask the actual validation error.
76
+ validateNapiCrossHost()
77
+ validateNapiCrossSupport(resolveTarget(options.target).triple)
78
+ }
79
+
65
80
  const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths)
66
81
 
67
82
  const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml')
68
- const metadata = await parseMetadata(manifestPath)
83
+ const metadata = await parseMetadata(manifestPath, {
84
+ features: options.features,
85
+ allFeatures: options.allFeatures,
86
+ noDefaultFeatures: options.noDefaultFeatures,
87
+ })
69
88
 
70
89
  const crate = metadata.packages.find((p) => {
71
90
  // package with given name
@@ -91,6 +110,285 @@ export async function buildProject(rawOptions: BuildOptions) {
91
110
  return builder.build()
92
111
  }
93
112
 
113
+ /**
114
+ * Resolve the target triple the build will run against, following the same
115
+ * precedence the build itself uses: the explicit `--target` option, then the
116
+ * `CARGO_BUILD_TARGET` environment variable, then the host default target.
117
+ */
118
+ function resolveTarget(targetOption?: string): Target {
119
+ return targetOption
120
+ ? parseTriple(targetOption)
121
+ : process.env.CARGO_BUILD_TARGET
122
+ ? parseTriple(process.env.CARGO_BUILD_TARGET)
123
+ : getSystemDefaultTarget()
124
+ }
125
+
126
+ /**
127
+ * Validate the combination of the cross-compilation related flags.
128
+ *
129
+ * `--use-cross`, `--use-napi-cross` and `--cross-compile` (`-x`) are three
130
+ * mutually exclusive cross-compilation mechanisms; combining any two of them
131
+ * leaves both active at once and produces broken builds, so it is rejected
132
+ * upfront before any side effect (like auto-installing cargo binaries or
133
+ * downloading toolchains) happens.
134
+ *
135
+ * `--cross-compile` with a `windows-gnu` target is rejected as well: on a
136
+ * non-Windows host it routes the build to `cargo xwin build`, but
137
+ * `cargo-xwin` only sets up MSVC toolchains — for `windows-gnu` targets it
138
+ * silently does nothing and the build dies much later with a cryptic
139
+ * ``error: linker `x86_64-w64-mingw32-gcc` not found`` that never mentions
140
+ * `cargo-xwin`. Only an explicitly requested target (`--target` or
141
+ * `CARGO_BUILD_TARGET`) is inspected, so this validation never has to spawn
142
+ * `rustc -vV`; a non-Windows host's default target can never be
143
+ * `windows-gnu` anyway.
144
+ */
145
+ export function validateCrossCompileFlags(
146
+ options: {
147
+ useCross?: boolean
148
+ crossCompile?: boolean
149
+ useNapiCross?: boolean
150
+ watch?: boolean
151
+ target?: string
152
+ },
153
+ hostPlatform: string = process.platform,
154
+ ): void {
155
+ const enabledCrossFlags = [
156
+ options.useCross ? '`--use-cross`' : null,
157
+ options.useNapiCross ? '`--use-napi-cross`' : null,
158
+ options.crossCompile ? '`--cross-compile` (`-x`)' : null,
159
+ ].filter((flag): flag is string => flag !== null)
160
+
161
+ if (enabledCrossFlags.length > 1) {
162
+ throw new Error(
163
+ `${enabledCrossFlags.join(' and ')} cannot be used together. Please pick exactly one cross-compilation mechanism: \`--use-cross\`, \`--use-napi-cross\`, or \`--cross-compile\` (\`-x\`).`,
164
+ )
165
+ }
166
+
167
+ if (options.watch && options.useCross) {
168
+ throw new Error(
169
+ '`--watch` cannot be used with `--use-cross`. `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.',
170
+ )
171
+ }
172
+
173
+ if (options.watch && options.crossCompile) {
174
+ throw new Error(
175
+ '`--watch` cannot be used with `--cross-compile` (`-x`). `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.',
176
+ )
177
+ }
178
+
179
+ // On a Windows host `--cross-compile` never picks `cargo-xwin` (it falls
180
+ // back to a plain `cargo build`), so windows-gnu targets are only broken
181
+ // on non-Windows hosts.
182
+ if (options.crossCompile && hostPlatform !== 'win32') {
183
+ const explicitTarget = options.target ?? process.env.CARGO_BUILD_TARGET
184
+ if (explicitTarget) {
185
+ const target = parseTriple(explicitTarget)
186
+ if (target.platform === 'win32' && target.abi?.startsWith('gnu')) {
187
+ const msvcTriple = explicitTarget.replace(/gnu(llvm)?$/, 'msvc')
188
+ // `*-windows-gnu` links with a mingw-w64 GCC toolchain, while
189
+ // `*-windows-gnullvm` needs an LLVM toolchain (llvm-mingw): `rustc`
190
+ // has no default working linker for it without one on the `PATH`.
191
+ const toolchainHint =
192
+ target.abi === 'gnullvm'
193
+ ? `an llvm-mingw (LLVM/Clang) toolchain on the \`PATH\` (\`rustc\` has no default working linker for ${explicitTarget} without one; \`napi-build\` additionally needs \`libnode.dll\` via the \`LIBNODE_PATH\` environment variable)`
194
+ : `a mingw-w64 toolchain (\`rustc\` uses the \`${explicitTarget.split('-')[0]}-w64-mingw32-gcc\` linker; \`napi-build\` additionally needs \`libnode.dll\` via the \`LIBNODE_PATH\` environment variable)`
195
+ throw new Error(
196
+ `\`--cross-compile\` (\`-x\`) does not support the target ${explicitTarget}: \`cargo-xwin\` only handles MSVC targets and the build would fail at link time. Drop \`-x\` and build with ${toolchainHint}, or target ${msvcTriple} instead.`,
197
+ )
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Validate that the current host can run the `@napi-rs/cross-toolchain`
205
+ * pre-built toolchains at all: they only run on Linux x64 and Linux arm64
206
+ * hosts. This check is separate from (and must run before) the per-target
207
+ * validation, because resolving the target may need to spawn `rustc`.
208
+ */
209
+ function validateNapiCrossHost(
210
+ hostPlatform: string = process.platform,
211
+ hostArch: string = process.arch,
212
+ ): asserts hostArch is 'x64' | 'arm64' {
213
+ if (
214
+ hostPlatform !== 'linux' ||
215
+ (hostArch !== 'x64' && hostArch !== 'arm64')
216
+ ) {
217
+ throw new Error(
218
+ `\`--use-napi-cross\` requires a Linux x64 or Linux arm64 host, but the current host is ${hostPlatform}-${hostArch}. Please use \`--cross-compile\` (\`-x\`) or \`--use-cross\` to cross compile on this host.`,
219
+ )
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Validate that `--use-napi-cross` can actually handle the requested target
225
+ * on the current host. The supported target set is read from the
226
+ * `@napi-rs/cross-toolchain` package, and the pre-built toolchains only run
227
+ * on Linux x64 and Linux arm64 hosts.
228
+ */
229
+ export function validateNapiCrossSupport(
230
+ targetTriple: string,
231
+ hostPlatform: string = process.platform,
232
+ hostArch: string = process.arch,
233
+ ): void {
234
+ validateNapiCrossHost(hostPlatform, hostArch)
235
+
236
+ const toolchains: Record<
237
+ 'x64' | 'arm64',
238
+ Record<string, string | undefined>
239
+ > = require('@napi-rs/cross-toolchain')
240
+ const supportedTargets = Object.keys(toolchains[hostArch])
241
+
242
+ if (!supportedTargets.includes(targetTriple)) {
243
+ throw new Error(
244
+ `\`--use-napi-cross\` does not support the target ${targetTriple}. Supported targets: ${supportedTargets.join(', ')}. Please use \`--cross-compile\` (\`-x\`) or \`--use-cross\` for this target.`,
245
+ )
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Compute the environment variables that route a `--use-napi-cross` build
251
+ * through the `@napi-rs/cross-toolchain` toolchain extracted at
252
+ * `toolchainPath`.
253
+ *
254
+ * Follows the same rule as `Builder#setEnvIfNotExists`: a variable the user
255
+ * already set in `env` wins and is not returned, and a present-but-empty
256
+ * variable counts as unset (falsy semantics). The only exceptions are the
257
+ * clang-specific `TARGET_CFLAGS`/`TARGET_CXXFLAGS`, which prepend the sysroot
258
+ * flags to the user's value, and `PATH`, which always gets the toolchain
259
+ * `bin` directory prepended.
260
+ *
261
+ * Exported for tests.
262
+ */
263
+ export function napiCrossToolchainEnvs(
264
+ toolchainPath: string,
265
+ targetTriple: string,
266
+ env: NodeJS.ProcessEnv = process.env,
267
+ ): Record<string, string> {
268
+ const alias: Record<string, string> = {
269
+ 's390x-unknown-linux-gnu': 's390x-ibm-linux-gnu',
270
+ }
271
+
272
+ const envs: Record<string, string> = {}
273
+ const setEnvIfNotExists = (name: string, value: string) => {
274
+ if (!env[name]) {
275
+ envs[name] = value
276
+ }
277
+ }
278
+
279
+ const upperCaseTarget = targetToEnvVar(targetTriple)
280
+ const crossTargetName = alias[targetTriple] ?? targetTriple
281
+ setEnvIfNotExists(
282
+ `CARGO_TARGET_${upperCaseTarget}_LINKER`,
283
+ join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
284
+ )
285
+ setEnvIfNotExists(
286
+ 'TARGET_SYSROOT',
287
+ join(toolchainPath, crossTargetName, 'sysroot'),
288
+ )
289
+ setEnvIfNotExists(
290
+ 'TARGET_AR',
291
+ join(toolchainPath, 'bin', `${crossTargetName}-ar`),
292
+ )
293
+ setEnvIfNotExists(
294
+ 'TARGET_RANLIB',
295
+ join(toolchainPath, 'bin', `${crossTargetName}-ranlib`),
296
+ )
297
+ setEnvIfNotExists(
298
+ 'TARGET_READELF',
299
+ join(toolchainPath, 'bin', `${crossTargetName}-readelf`),
300
+ )
301
+ setEnvIfNotExists(
302
+ 'TARGET_C_INCLUDE_PATH',
303
+ join(toolchainPath, crossTargetName, 'sysroot', 'usr', 'include/'),
304
+ )
305
+ setEnvIfNotExists(
306
+ 'TARGET_CC',
307
+ join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
308
+ )
309
+ setEnvIfNotExists(
310
+ 'TARGET_CXX',
311
+ join(toolchainPath, 'bin', `${crossTargetName}-g++`),
312
+ )
313
+ // `setEnvIfNotExists` skips `envs` when the user already set the variable
314
+ // in their environment, so read the effective value back from `env` first
315
+ // — with the same falsy semantics: a present-but-empty `TARGET_SYSROOT`
316
+ // counts as unset and falls back to the downloaded sysroot (`??` would
317
+ // keep the empty string and produce a broken `--sysroot=`).
318
+ const targetSysroot = env.TARGET_SYSROOT || envs.TARGET_SYSROOT
319
+ setEnvIfNotExists('BINDGEN_EXTRA_CLANG_ARGS', `--sysroot=${targetSysroot}`)
320
+
321
+ // cc-rs parses the env value before executing it (`env_tool` in cc's
322
+ // lib.rs): when the WHOLE value exists on the filesystem (`check_exe`)
323
+ // it is the compiler as-is — that is how it supports spaces in paths
324
+ // like `/opt/LLVM 18/bin/clang` — and only otherwise is the value split
325
+ // on whitespace; then, when the first token is a known wrapper
326
+ // (`sccache clang`) the second token is the compiler that actually runs,
327
+ // otherwise the first token is and the rest are arguments
328
+ // (`clang -target …`). Mirror that ordering, filesystem probe included:
329
+ // a pure whole-value basename heuristic would misread argument forms
330
+ // that merely END in `clang`, like `gcc --sysroot=/opt/clang`, and
331
+ // inject clang-only flags into a gcc compile. Match on the executable
332
+ // name so path-qualified (`/usr/bin/clang`), triple-prefixed
333
+ // (`aarch64-linux-gnu-clang`) and versioned (`clang-18`) compilers are
334
+ // all recognized — but not clang-family tools that are not compilers
335
+ // (`clang-format`).
336
+ const ccWrappers = new Set([
337
+ 'ccache',
338
+ 'distcc',
339
+ 'sccache',
340
+ 'icecc',
341
+ 'cachepot',
342
+ 'buildcache',
343
+ 'kache',
344
+ ])
345
+ const clangExecutableName = /(^|-)clang(\+\+)?(-\d+)?$/
346
+ // cc-rs's `check_exe` only probes for existence (plus an `.exe` retry on
347
+ // Windows, where this napi-cross path never runs); requiring a regular
348
+ // file additionally keeps directories from being taken for compilers.
349
+ const isExistingFile = (path: string): boolean => {
350
+ try {
351
+ return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
352
+ } catch {
353
+ return false
354
+ }
355
+ }
356
+ const isClangCompiler = (value: string | undefined): boolean => {
357
+ if (!value) {
358
+ return false
359
+ }
360
+ const trimmed = value.trim()
361
+ if (isExistingFile(trimmed)) {
362
+ return clangExecutableName.test(basename(trimmed))
363
+ }
364
+ const [first, second] = trimmed.split(/\s+/)
365
+ const compiler = first && ccWrappers.has(basename(first)) ? second : first
366
+ return (
367
+ compiler !== undefined && clangExecutableName.test(basename(compiler))
368
+ )
369
+ }
370
+
371
+ // Detect clang on the EFFECTIVE target compiler: the user's TARGET_CC if
372
+ // set, otherwise the toolchain gcc exported above (`envs.TARGET_CC`).
373
+ // cc-rs prefers TARGET_CC over CC for cross builds, so a plain `CC=clang`
374
+ // never runs here and must not trigger the clang-only `--gcc-toolchain=`
375
+ // flag (gcc hard-errors on it). The trailing `env.CC`/`env.CXX` terms are
376
+ // unreachable today (exactly one of the first two is always truthy) but
377
+ // keep the check correct if the toolchain default ever becomes conditional.
378
+ if (isClangCompiler(env.TARGET_CC || envs.TARGET_CC || env.CC)) {
379
+ const TARGET_CFLAGS = env.TARGET_CFLAGS || ''
380
+ envs.TARGET_CFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CFLAGS}`
381
+ }
382
+ if (isClangCompiler(env.TARGET_CXX || envs.TARGET_CXX || env.CXX)) {
383
+ const TARGET_CXXFLAGS = env.TARGET_CXXFLAGS || ''
384
+ envs.TARGET_CXXFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CXXFLAGS}`
385
+ }
386
+ envs.PATH = env.PATH
387
+ ? `${toolchainPath}/bin:${env.PATH}`
388
+ : `${toolchainPath}/bin`
389
+ return envs
390
+ }
391
+
94
392
  class Builder {
95
393
  private readonly args: string[] = []
96
394
  private readonly envs: Record<string, string> = {}
@@ -108,11 +406,7 @@ class Builder {
108
406
  private readonly config: NapiConfig,
109
407
  private readonly options: ParsedBuildOptions,
110
408
  ) {
111
- this.target = options.target
112
- ? parseTriple(options.target)
113
- : process.env.CARGO_BUILD_TARGET
114
- ? parseTriple(process.env.CARGO_BUILD_TARGET)
115
- : getSystemDefaultTarget()
409
+ this.target = resolveTarget(options.target)
116
410
  this.crateDir = parse(crate.manifest_path).dir
117
411
  this.outputDir = resolve(
118
412
  this.options.cwd,
@@ -149,6 +443,9 @@ class Builder {
149
443
  }
150
444
 
151
445
  get cdyLibName() {
446
+ if (this.options.bin) {
447
+ return
448
+ }
152
449
  return this.crate.targets.find((t) => t.crate_types.includes('cdylib'))
153
450
  ?.name
154
451
  }
@@ -164,7 +461,19 @@ class Builder {
164
461
  }
165
462
 
166
463
  build() {
167
- if (!this.cdyLibName) {
464
+ // Backstop only: `buildProject()` already validated these before running
465
+ // anything with a side effect (see the top of `buildProject`). Kept here
466
+ // so a directly constructed `Builder` cannot skip the validation.
467
+ validateCrossCompileFlags(this.options)
468
+ if (this.options.useNapiCross) {
469
+ validateNapiCrossSupport(this.target.triple)
470
+ }
471
+
472
+ if (this.options.bin) {
473
+ debug.warn(
474
+ `Building Cargo binary target ${this.binName}; the result will be an executable, not a Node.js addon.`,
475
+ )
476
+ } else if (!this.cdyLibName) {
168
477
  const warning =
169
478
  'Missing `crate-type = ["cdylib"]` in [lib] config. The build result will not be available as node addon.'
170
479
 
@@ -189,25 +498,10 @@ class Builder {
189
498
  if (!this.options.useNapiCross) {
190
499
  return this
191
500
  }
192
- if (this.options.useCross) {
193
- debug.warn(
194
- 'You are trying to use both `--cross` and `--use-napi-cross` options, `--use-cross` will be ignored.',
195
- )
196
- }
197
-
198
- if (this.options.crossCompile) {
199
- debug.warn(
200
- 'You are trying to use both `--cross-compile` and `--use-napi-cross` options, `--cross-compile` will be ignored.',
201
- )
202
- }
203
501
 
204
502
  try {
205
503
  const { version, download } = require('@napi-rs/cross-toolchain')
206
504
 
207
- const alias: Record<string, string> = {
208
- 's390x-unknown-linux-gnu': 's390x-ibm-linux-gnu',
209
- }
210
-
211
505
  const toolchainPath = join(
212
506
  homedir(),
213
507
  '.napi-rs',
@@ -222,66 +516,15 @@ class Builder {
222
516
  const tarArchive = download(process.arch, this.target.triple)
223
517
  tarArchive.unpack(toolchainPath)
224
518
  }
225
- const upperCaseTarget = targetToEnvVar(this.target.triple)
226
- const crossTargetName = alias[this.target.triple] ?? this.target.triple
227
- const linkerEnv = `CARGO_TARGET_${upperCaseTarget}_LINKER`
228
- this.setEnvIfNotExists(
229
- linkerEnv,
230
- join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
231
- )
232
- this.setEnvIfNotExists(
233
- 'TARGET_SYSROOT',
234
- join(toolchainPath, crossTargetName, 'sysroot'),
235
- )
236
- this.setEnvIfNotExists(
237
- 'TARGET_AR',
238
- join(toolchainPath, 'bin', `${crossTargetName}-ar`),
239
- )
240
- this.setEnvIfNotExists(
241
- 'TARGET_RANLIB',
242
- join(toolchainPath, 'bin', `${crossTargetName}-ranlib`),
243
- )
244
- this.setEnvIfNotExists(
245
- 'TARGET_READELF',
246
- join(toolchainPath, 'bin', `${crossTargetName}-readelf`),
247
- )
248
- this.setEnvIfNotExists(
249
- 'TARGET_C_INCLUDE_PATH',
250
- join(toolchainPath, crossTargetName, 'sysroot', 'usr', 'include/'),
519
+ Object.assign(
520
+ this.envs,
521
+ napiCrossToolchainEnvs(toolchainPath, this.target.triple),
251
522
  )
252
- this.setEnvIfNotExists(
253
- 'TARGET_CC',
254
- join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
255
- )
256
- this.setEnvIfNotExists(
257
- 'TARGET_CXX',
258
- join(toolchainPath, 'bin', `${crossTargetName}-g++`),
259
- )
260
- this.setEnvIfNotExists(
261
- 'BINDGEN_EXTRA_CLANG_ARGS',
262
- `--sysroot=${this.envs.TARGET_SYSROOT}}`,
263
- )
264
-
265
- if (
266
- process.env.TARGET_CC?.startsWith('clang') ||
267
- (process.env.CC?.startsWith('clang') && !process.env.TARGET_CC)
268
- ) {
269
- const TARGET_CFLAGS = process.env.TARGET_CFLAGS ?? ''
270
- this.envs.TARGET_CFLAGS = `--sysroot=${this.envs.TARGET_SYSROOT} --gcc-toolchain=${toolchainPath} ${TARGET_CFLAGS}`
271
- }
272
- if (
273
- (process.env.CXX?.startsWith('clang++') && !process.env.TARGET_CXX) ||
274
- process.env.TARGET_CXX?.startsWith('clang++')
275
- ) {
276
- const TARGET_CXXFLAGS = process.env.TARGET_CXXFLAGS ?? ''
277
- this.envs.TARGET_CXXFLAGS = `--sysroot=${this.envs.TARGET_SYSROOT} --gcc-toolchain=${toolchainPath} ${TARGET_CXXFLAGS}`
278
- }
279
- this.envs.PATH = this.envs.PATH
280
- ? `${toolchainPath}/bin:${this.envs.PATH}:${process.env.PATH}`
281
- : `${toolchainPath}/bin:${process.env.PATH}`
282
523
  } catch (e) {
283
- debug.warn('Pick cross toolchain failed', e as Error)
284
- // ignore, do nothing
524
+ throw new Error(
525
+ `Failed to set up the \`--use-napi-cross\` toolchain for ${this.target.triple}: ${(e as Error).message}. Check filesystem permissions and network connectivity to the npm registry, then retry, or use \`--cross-compile\` (\`-x\`) / \`--use-cross\` instead.`,
526
+ { cause: e },
527
+ )
285
528
  }
286
529
  return this
287
530
  }
@@ -294,13 +537,21 @@ class Builder {
294
537
 
295
538
  const watch = this.options.watch
296
539
  const buildTask = new Promise<void>((resolve, reject) => {
297
- if (this.options.useCross && this.options.crossCompile) {
298
- throw new Error(
299
- '`--use-cross` and `--cross-compile` can not be used together',
540
+ const cargoOverride = process.env.CARGO
541
+ if (
542
+ cargoOverride &&
543
+ (this.options.useCross || this.options.crossCompile)
544
+ ) {
545
+ const expectedBinary = this.options.useCross ? 'cross' : 'cargo'
546
+ const requestedFlag = this.options.useCross
547
+ ? '`--use-cross`'
548
+ : '`--cross-compile` (`-x`)'
549
+ debug.warn(
550
+ `The \`CARGO\` environment variable is set to \`${cargoOverride}\`; it will be spawned instead of the \`${expectedBinary}\` binary that ${requestedFlag} relies on. Unset \`CARGO\` if this is not intended.`,
300
551
  )
301
552
  }
302
553
  const command =
303
- process.env.CARGO ?? (this.options.useCross ? 'cross' : 'cargo')
554
+ cargoOverride ?? (this.options.useCross ? 'cross' : 'cargo')
304
555
  const buildProcess = spawn(command, this.args, {
305
556
  env: { ...process.env, ...this.envs },
306
557
  stdio: watch ? ['inherit', 'inherit', 'pipe'] : 'inherit',
@@ -345,10 +596,9 @@ class Builder {
345
596
  } else {
346
597
  debug('Use %i', 'cargo-watch')
347
598
  tryInstallCargoBinary('cargo-watch', 'watch')
348
- // yarn napi watch --target x86_64-unknown-linux-gnu [--cross-compile]
599
+ // yarn napi watch --target x86_64-unknown-linux-gnu
349
600
  // ===>
350
- // cargo watch [...] -- build --target x86_64-unknown-linux-gnu
351
- // cargo watch [...] -- zigbuild --target x86_64-unknown-linux-gnu
601
+ // cargo watch [...] -- cargo build --target x86_64-unknown-linux-gnu
352
602
  this.args.push(
353
603
  'watch',
354
604
  '--why',
@@ -489,11 +739,8 @@ class Builder {
489
739
 
490
740
  private setForceBuildEnvs(typeDefTmpFolder: string) {
491
741
  // dynamically check all napi-rs deps and set `NAPI_FORCE_BUILD_{uppercase(snake_case(name))} = timestamp`
492
- this.metadata.packages.forEach((crate) => {
493
- if (
494
- crate.dependencies.some((d) => d.name === 'napi-derive') &&
495
- !existsSync(join(typeDefTmpFolder, crate.name))
496
- ) {
742
+ getNapiDeriveDependentCrates(this.metadata).forEach((crate) => {
743
+ if (!existsSync(join(typeDefTmpFolder, crate.name))) {
497
744
  this.envs[
498
745
  `NAPI_FORCE_BUILD_${crate.name.replace(/-/g, '_').toUpperCase()}`
499
746
  ] = Date.now().toString()
@@ -502,20 +749,20 @@ class Builder {
502
749
  }
503
750
 
504
751
  private setAndroidEnv() {
752
+ // Native Android hosts and `cross` provide their own Android toolchains.
753
+ if (process.platform === 'android' || this.options.useCross) {
754
+ return
755
+ }
756
+
505
757
  const { ANDROID_NDK_LATEST_HOME } = process.env
506
758
  if (!ANDROID_NDK_LATEST_HOME) {
507
- debug.warn(
759
+ throw new Error(
508
760
  `${colors.red(
509
761
  'ANDROID_NDK_LATEST_HOME',
510
- )} environment variable is missing`,
762
+ )} environment variable is required when building an Android target from a non-Android host`,
511
763
  )
512
764
  }
513
765
 
514
- // skip cross compile setup if host is android
515
- if (process.platform === 'android') {
516
- return
517
- }
518
-
519
766
  const targetArch = this.target.arch === 'arm' ? 'armv7a' : 'aarch64'
520
767
  const targetPlatform =
521
768
  this.target.arch === 'arm' ? 'androideabi24' : 'android24'
@@ -1075,18 +1322,14 @@ export async function generateTypeDef(
1075
1322
 
1076
1323
  if (!options.noDtsHeader) {
1077
1324
  const dtsHeader = options.dtsHeader ?? options.configDtsHeader
1078
- // `dtsHeaderFile` in config > `dtsHeader` in cli flag > `dtsHeader` in config
1079
- if (options.configDtsHeaderFile) {
1325
+ const dtsHeaderFile = options.dtsHeaderFile ?? options.configDtsHeaderFile
1326
+ // An explicit API header file takes precedence over the config file;
1327
+ // either file takes precedence over inline header text.
1328
+ if (dtsHeaderFile) {
1080
1329
  try {
1081
- header = await readFileAsync(
1082
- join(options.cwd, options.configDtsHeaderFile),
1083
- 'utf-8',
1084
- )
1330
+ header = await readFileAsync(join(options.cwd, dtsHeaderFile), 'utf-8')
1085
1331
  } catch (e) {
1086
- debug.warn(
1087
- `Failed to read dts header file ${options.configDtsHeaderFile}`,
1088
- e,
1089
- )
1332
+ debug.warn(`Failed to read dts header file ${dtsHeaderFile}`, e)
1090
1333
  }
1091
1334
  } else if (dtsHeader) {
1092
1335
  header = dtsHeader
package/src/api/new.ts CHANGED
@@ -21,7 +21,10 @@ import {
21
21
  statAsync,
22
22
  type SupportedPackageManager,
23
23
  } from '../utils/index.js'
24
- import { napiEngineRequirement } from '../utils/version.js'
24
+ import {
25
+ napiEngineRequirement,
26
+ SUPPORTED_NAPI_VERSIONS,
27
+ } from '../utils/version.js'
25
28
  import { renameProject } from './rename.js'
26
29
 
27
30
  // Template imports removed as we're now using external templates
@@ -210,6 +213,52 @@ async function updateCargoTomlTypeDef(
210
213
  await fs.writeFile(filePath, stringifyToml(cargoToml))
211
214
  }
212
215
 
216
+ export async function updateCargoTomlNodeApiVersion(
217
+ filePath: string,
218
+ minNodeApiVersion: number,
219
+ ): Promise<void> {
220
+ const content = await fs.readFile(filePath, 'utf-8')
221
+ const cargoToml = parseToml(content) as Record<string, any>
222
+ const dependencies = cargoToml.dependencies
223
+
224
+ if (!dependencies || !dependencies.napi) {
225
+ return
226
+ }
227
+
228
+ const napiDependency = dependencies.napi
229
+ const dependencyConfig =
230
+ typeof napiDependency === 'string'
231
+ ? { version: napiDependency }
232
+ : { ...napiDependency }
233
+ const usedDefaultFeatures = dependencyConfig['default-features'] !== false
234
+ const existingFeatures: string[] = Array.isArray(dependencyConfig.features)
235
+ ? dependencyConfig.features.filter(
236
+ (feature: unknown): feature is string => typeof feature === 'string',
237
+ )
238
+ : []
239
+
240
+ dependencyConfig.features = [
241
+ `napi${minNodeApiVersion}`,
242
+ ...existingFeatures.filter((feature) => !/^napi\d+$/.test(feature)),
243
+ ]
244
+
245
+ // napi's default features include napi4. Disable them for lower requested
246
+ // levels, while retaining the default dynamic Node-API symbol loading mode.
247
+ if (minNodeApiVersion < 4) {
248
+ dependencyConfig['default-features'] = false
249
+ if (
250
+ usedDefaultFeatures &&
251
+ !dependencyConfig.features.includes('dyn-symbols')
252
+ ) {
253
+ dependencyConfig.features.push('dyn-symbols')
254
+ }
255
+ }
256
+
257
+ dependencies.napi = dependencyConfig
258
+
259
+ await fs.writeFile(filePath, stringifyToml(cargoToml))
260
+ }
261
+
213
262
  async function filterTargetsInGithubActions(
214
263
  filePath: string,
215
264
  enabledTargets: string[],
@@ -341,6 +390,17 @@ async function filterTargetsInGithubActions(
341
390
 
342
391
  function processOptions(options: RawNewOptions) {
343
392
  debug('Processing options...')
393
+ const minNodeApiVersion = options.minNodeApiVersion ?? 4
394
+ if (
395
+ !Number.isInteger(minNodeApiVersion) ||
396
+ !SUPPORTED_NAPI_VERSIONS.some((version) => version === minNodeApiVersion)
397
+ ) {
398
+ throw new RangeError(
399
+ `Unsupported Node-API version ${minNodeApiVersion}. Expected one of: ${SUPPORTED_NAPI_VERSIONS.join(', ')}`,
400
+ )
401
+ }
402
+ options.minNodeApiVersion = minNodeApiVersion
403
+
344
404
  if (!options.path) {
345
405
  throw new Error('Please provide the path as the argument')
346
406
  }
@@ -424,6 +484,10 @@ export async function newProject(userOptions: RawNewOptions) {
424
484
  const cargoTomlPath = path.join(options.path, 'Cargo.toml')
425
485
  if (existsSync(cargoTomlPath)) {
426
486
  await updateCargoTomlTypeDef(cargoTomlPath, options.enableTypeDef)
487
+ await updateCargoTomlNodeApiVersion(
488
+ cargoTomlPath,
489
+ options.minNodeApiVersion,
490
+ )
427
491
  }
428
492
 
429
493
  // Filter targets in package.json
@@ -0,0 +1,13 @@
1
+ import test from 'ava'
2
+
3
+ import { getNapiVersionChoices } from '../new.js'
4
+ import { SUPPORTED_NAPI_VERSIONS } from '../../utils/version.js'
5
+
6
+ test('napi version prompt choices track supported versions', (t) => {
7
+ const choices = getNapiVersionChoices()
8
+
9
+ t.deepEqual(
10
+ choices.map((choice) => choice.value),
11
+ SUPPORTED_NAPI_VERSIONS,
12
+ )
13
+ })