@napi-rs/cli 3.7.2 → 3.7.3

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 (40) hide show
  1. package/dist/cli.js +201 -52
  2. package/dist/index.cjs +200 -51
  3. package/dist/index.d.cts +4 -4
  4. package/dist/index.d.ts +4 -4
  5. package/dist/index.js +201 -52
  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 +346 -105
  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__/runtime_string_enum_flag +2 -0
  26. package/src/utils/__tests__/__snapshots__/config.spec.ts.md +97 -0
  27. package/src/utils/__tests__/__snapshots__/config.spec.ts.snap +0 -0
  28. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +180 -0
  29. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  30. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.md +1197 -0
  31. package/src/utils/__tests__/__snapshots__/typegen.spec.ts.snap +0 -0
  32. package/src/utils/__tests__/__snapshots__/version.spec.ts.md +22 -0
  33. package/src/utils/__tests__/__snapshots__/version.spec.ts.snap +0 -0
  34. package/src/utils/__tests__/config.spec.ts +81 -0
  35. package/src/utils/__tests__/metadata.spec.ts +28 -0
  36. package/src/utils/__tests__/misc.spec.ts +56 -0
  37. package/src/utils/__tests__/target.spec.ts +19 -0
  38. package/src/utils/__tests__/typegen.spec.ts +85 -0
  39. package/src/utils/__tests__/version.spec.ts +7 -0
  40. 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
 
@@ -62,6 +62,20 @@ export async function buildProject(rawOptions: BuildOptions) {
62
62
  cwd: rawOptions.cwd ?? process.cwd(),
63
63
  }
64
64
 
65
+ // Reject invalid cross-compilation flag combinations before anything with
66
+ // a side effect runs (`cargo metadata`, cargo binary auto-installs,
67
+ // toolchain downloads, ...), so the user always gets the validation error
68
+ // rather than an unrelated failure from those steps.
69
+ validateCrossCompileFlags(options)
70
+ if (options.useNapiCross) {
71
+ // Check the host constraint before resolving the target: without an
72
+ // explicit `--target` (or `CARGO_BUILD_TARGET`) the target resolution
73
+ // spawns `rustc -vV`, and on an unsupported host without Rust on the
74
+ // `PATH` that spawn failure would mask the actual validation error.
75
+ validateNapiCrossHost()
76
+ validateNapiCrossSupport(resolveTarget(options.target).triple)
77
+ }
78
+
65
79
  const resolvePath = (...paths: string[]) => resolve(options.cwd, ...paths)
66
80
 
67
81
  const manifestPath = resolvePath(options.manifestPath ?? 'Cargo.toml')
@@ -91,6 +105,285 @@ export async function buildProject(rawOptions: BuildOptions) {
91
105
  return builder.build()
92
106
  }
93
107
 
108
+ /**
109
+ * Resolve the target triple the build will run against, following the same
110
+ * precedence the build itself uses: the explicit `--target` option, then the
111
+ * `CARGO_BUILD_TARGET` environment variable, then the host default target.
112
+ */
113
+ function resolveTarget(targetOption?: string): Target {
114
+ return targetOption
115
+ ? parseTriple(targetOption)
116
+ : process.env.CARGO_BUILD_TARGET
117
+ ? parseTriple(process.env.CARGO_BUILD_TARGET)
118
+ : getSystemDefaultTarget()
119
+ }
120
+
121
+ /**
122
+ * Validate the combination of the cross-compilation related flags.
123
+ *
124
+ * `--use-cross`, `--use-napi-cross` and `--cross-compile` (`-x`) are three
125
+ * mutually exclusive cross-compilation mechanisms; combining any two of them
126
+ * leaves both active at once and produces broken builds, so it is rejected
127
+ * upfront before any side effect (like auto-installing cargo binaries or
128
+ * downloading toolchains) happens.
129
+ *
130
+ * `--cross-compile` with a `windows-gnu` target is rejected as well: on a
131
+ * non-Windows host it routes the build to `cargo xwin build`, but
132
+ * `cargo-xwin` only sets up MSVC toolchains — for `windows-gnu` targets it
133
+ * silently does nothing and the build dies much later with a cryptic
134
+ * ``error: linker `x86_64-w64-mingw32-gcc` not found`` that never mentions
135
+ * `cargo-xwin`. Only an explicitly requested target (`--target` or
136
+ * `CARGO_BUILD_TARGET`) is inspected, so this validation never has to spawn
137
+ * `rustc -vV`; a non-Windows host's default target can never be
138
+ * `windows-gnu` anyway.
139
+ */
140
+ export function validateCrossCompileFlags(
141
+ options: {
142
+ useCross?: boolean
143
+ crossCompile?: boolean
144
+ useNapiCross?: boolean
145
+ watch?: boolean
146
+ target?: string
147
+ },
148
+ hostPlatform: string = process.platform,
149
+ ): void {
150
+ const enabledCrossFlags = [
151
+ options.useCross ? '`--use-cross`' : null,
152
+ options.useNapiCross ? '`--use-napi-cross`' : null,
153
+ options.crossCompile ? '`--cross-compile` (`-x`)' : null,
154
+ ].filter((flag): flag is string => flag !== null)
155
+
156
+ if (enabledCrossFlags.length > 1) {
157
+ throw new Error(
158
+ `${enabledCrossFlags.join(' and ')} cannot be used together. Please pick exactly one cross-compilation mechanism: \`--use-cross\`, \`--use-napi-cross\`, or \`--cross-compile\` (\`-x\`).`,
159
+ )
160
+ }
161
+
162
+ if (options.watch && options.useCross) {
163
+ throw new Error(
164
+ '`--watch` cannot be used with `--use-cross`. `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.',
165
+ )
166
+ }
167
+
168
+ if (options.watch && options.crossCompile) {
169
+ throw new Error(
170
+ '`--watch` cannot be used with `--cross-compile` (`-x`). `cargo watch` only supports the plain `cargo build` flow, please drop one of the two flags.',
171
+ )
172
+ }
173
+
174
+ // On a Windows host `--cross-compile` never picks `cargo-xwin` (it falls
175
+ // back to a plain `cargo build`), so windows-gnu targets are only broken
176
+ // on non-Windows hosts.
177
+ if (options.crossCompile && hostPlatform !== 'win32') {
178
+ const explicitTarget = options.target ?? process.env.CARGO_BUILD_TARGET
179
+ if (explicitTarget) {
180
+ const target = parseTriple(explicitTarget)
181
+ if (target.platform === 'win32' && target.abi?.startsWith('gnu')) {
182
+ const msvcTriple = explicitTarget.replace(/gnu(llvm)?$/, 'msvc')
183
+ // `*-windows-gnu` links with a mingw-w64 GCC toolchain, while
184
+ // `*-windows-gnullvm` needs an LLVM toolchain (llvm-mingw): `rustc`
185
+ // has no default working linker for it without one on the `PATH`.
186
+ const toolchainHint =
187
+ target.abi === 'gnullvm'
188
+ ? `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)`
189
+ : `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)`
190
+ throw new Error(
191
+ `\`--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.`,
192
+ )
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Validate that the current host can run the `@napi-rs/cross-toolchain`
200
+ * pre-built toolchains at all: they only run on Linux x64 and Linux arm64
201
+ * hosts. This check is separate from (and must run before) the per-target
202
+ * validation, because resolving the target may need to spawn `rustc`.
203
+ */
204
+ function validateNapiCrossHost(
205
+ hostPlatform: string = process.platform,
206
+ hostArch: string = process.arch,
207
+ ): asserts hostArch is 'x64' | 'arm64' {
208
+ if (
209
+ hostPlatform !== 'linux' ||
210
+ (hostArch !== 'x64' && hostArch !== 'arm64')
211
+ ) {
212
+ throw new Error(
213
+ `\`--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.`,
214
+ )
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Validate that `--use-napi-cross` can actually handle the requested target
220
+ * on the current host. The supported target set is read from the
221
+ * `@napi-rs/cross-toolchain` package, and the pre-built toolchains only run
222
+ * on Linux x64 and Linux arm64 hosts.
223
+ */
224
+ export function validateNapiCrossSupport(
225
+ targetTriple: string,
226
+ hostPlatform: string = process.platform,
227
+ hostArch: string = process.arch,
228
+ ): void {
229
+ validateNapiCrossHost(hostPlatform, hostArch)
230
+
231
+ const toolchains: Record<
232
+ 'x64' | 'arm64',
233
+ Record<string, string | undefined>
234
+ > = require('@napi-rs/cross-toolchain')
235
+ const supportedTargets = Object.keys(toolchains[hostArch])
236
+
237
+ if (!supportedTargets.includes(targetTriple)) {
238
+ throw new Error(
239
+ `\`--use-napi-cross\` does not support the target ${targetTriple}. Supported targets: ${supportedTargets.join(', ')}. Please use \`--cross-compile\` (\`-x\`) or \`--use-cross\` for this target.`,
240
+ )
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Compute the environment variables that route a `--use-napi-cross` build
246
+ * through the `@napi-rs/cross-toolchain` toolchain extracted at
247
+ * `toolchainPath`.
248
+ *
249
+ * Follows the same rule as `Builder#setEnvIfNotExists`: a variable the user
250
+ * already set in `env` wins and is not returned, and a present-but-empty
251
+ * variable counts as unset (falsy semantics). The only exceptions are the
252
+ * clang-specific `TARGET_CFLAGS`/`TARGET_CXXFLAGS`, which prepend the sysroot
253
+ * flags to the user's value, and `PATH`, which always gets the toolchain
254
+ * `bin` directory prepended.
255
+ *
256
+ * Exported for tests.
257
+ */
258
+ export function napiCrossToolchainEnvs(
259
+ toolchainPath: string,
260
+ targetTriple: string,
261
+ env: NodeJS.ProcessEnv = process.env,
262
+ ): Record<string, string> {
263
+ const alias: Record<string, string> = {
264
+ 's390x-unknown-linux-gnu': 's390x-ibm-linux-gnu',
265
+ }
266
+
267
+ const envs: Record<string, string> = {}
268
+ const setEnvIfNotExists = (name: string, value: string) => {
269
+ if (!env[name]) {
270
+ envs[name] = value
271
+ }
272
+ }
273
+
274
+ const upperCaseTarget = targetToEnvVar(targetTriple)
275
+ const crossTargetName = alias[targetTriple] ?? targetTriple
276
+ setEnvIfNotExists(
277
+ `CARGO_TARGET_${upperCaseTarget}_LINKER`,
278
+ join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
279
+ )
280
+ setEnvIfNotExists(
281
+ 'TARGET_SYSROOT',
282
+ join(toolchainPath, crossTargetName, 'sysroot'),
283
+ )
284
+ setEnvIfNotExists(
285
+ 'TARGET_AR',
286
+ join(toolchainPath, 'bin', `${crossTargetName}-ar`),
287
+ )
288
+ setEnvIfNotExists(
289
+ 'TARGET_RANLIB',
290
+ join(toolchainPath, 'bin', `${crossTargetName}-ranlib`),
291
+ )
292
+ setEnvIfNotExists(
293
+ 'TARGET_READELF',
294
+ join(toolchainPath, 'bin', `${crossTargetName}-readelf`),
295
+ )
296
+ setEnvIfNotExists(
297
+ 'TARGET_C_INCLUDE_PATH',
298
+ join(toolchainPath, crossTargetName, 'sysroot', 'usr', 'include/'),
299
+ )
300
+ setEnvIfNotExists(
301
+ 'TARGET_CC',
302
+ join(toolchainPath, 'bin', `${crossTargetName}-gcc`),
303
+ )
304
+ setEnvIfNotExists(
305
+ 'TARGET_CXX',
306
+ join(toolchainPath, 'bin', `${crossTargetName}-g++`),
307
+ )
308
+ // `setEnvIfNotExists` skips `envs` when the user already set the variable
309
+ // in their environment, so read the effective value back from `env` first
310
+ // — with the same falsy semantics: a present-but-empty `TARGET_SYSROOT`
311
+ // counts as unset and falls back to the downloaded sysroot (`??` would
312
+ // keep the empty string and produce a broken `--sysroot=`).
313
+ const targetSysroot = env.TARGET_SYSROOT || envs.TARGET_SYSROOT
314
+ setEnvIfNotExists('BINDGEN_EXTRA_CLANG_ARGS', `--sysroot=${targetSysroot}`)
315
+
316
+ // cc-rs parses the env value before executing it (`env_tool` in cc's
317
+ // lib.rs): when the WHOLE value exists on the filesystem (`check_exe`)
318
+ // it is the compiler as-is — that is how it supports spaces in paths
319
+ // like `/opt/LLVM 18/bin/clang` — and only otherwise is the value split
320
+ // on whitespace; then, when the first token is a known wrapper
321
+ // (`sccache clang`) the second token is the compiler that actually runs,
322
+ // otherwise the first token is and the rest are arguments
323
+ // (`clang -target …`). Mirror that ordering, filesystem probe included:
324
+ // a pure whole-value basename heuristic would misread argument forms
325
+ // that merely END in `clang`, like `gcc --sysroot=/opt/clang`, and
326
+ // inject clang-only flags into a gcc compile. Match on the executable
327
+ // name so path-qualified (`/usr/bin/clang`), triple-prefixed
328
+ // (`aarch64-linux-gnu-clang`) and versioned (`clang-18`) compilers are
329
+ // all recognized — but not clang-family tools that are not compilers
330
+ // (`clang-format`).
331
+ const ccWrappers = new Set([
332
+ 'ccache',
333
+ 'distcc',
334
+ 'sccache',
335
+ 'icecc',
336
+ 'cachepot',
337
+ 'buildcache',
338
+ 'kache',
339
+ ])
340
+ const clangExecutableName = /(^|-)clang(\+\+)?(-\d+)?$/
341
+ // cc-rs's `check_exe` only probes for existence (plus an `.exe` retry on
342
+ // Windows, where this napi-cross path never runs); requiring a regular
343
+ // file additionally keeps directories from being taken for compilers.
344
+ const isExistingFile = (path: string): boolean => {
345
+ try {
346
+ return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
347
+ } catch {
348
+ return false
349
+ }
350
+ }
351
+ const isClangCompiler = (value: string | undefined): boolean => {
352
+ if (!value) {
353
+ return false
354
+ }
355
+ const trimmed = value.trim()
356
+ if (isExistingFile(trimmed)) {
357
+ return clangExecutableName.test(basename(trimmed))
358
+ }
359
+ const [first, second] = trimmed.split(/\s+/)
360
+ const compiler = first && ccWrappers.has(basename(first)) ? second : first
361
+ return (
362
+ compiler !== undefined && clangExecutableName.test(basename(compiler))
363
+ )
364
+ }
365
+
366
+ // Detect clang on the EFFECTIVE target compiler: the user's TARGET_CC if
367
+ // set, otherwise the toolchain gcc exported above (`envs.TARGET_CC`).
368
+ // cc-rs prefers TARGET_CC over CC for cross builds, so a plain `CC=clang`
369
+ // never runs here and must not trigger the clang-only `--gcc-toolchain=`
370
+ // flag (gcc hard-errors on it). The trailing `env.CC`/`env.CXX` terms are
371
+ // unreachable today (exactly one of the first two is always truthy) but
372
+ // keep the check correct if the toolchain default ever becomes conditional.
373
+ if (isClangCompiler(env.TARGET_CC || envs.TARGET_CC || env.CC)) {
374
+ const TARGET_CFLAGS = env.TARGET_CFLAGS || ''
375
+ envs.TARGET_CFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CFLAGS}`
376
+ }
377
+ if (isClangCompiler(env.TARGET_CXX || envs.TARGET_CXX || env.CXX)) {
378
+ const TARGET_CXXFLAGS = env.TARGET_CXXFLAGS || ''
379
+ envs.TARGET_CXXFLAGS = `--sysroot=${targetSysroot} --gcc-toolchain=${toolchainPath} ${TARGET_CXXFLAGS}`
380
+ }
381
+ envs.PATH = env.PATH
382
+ ? `${toolchainPath}/bin:${env.PATH}`
383
+ : `${toolchainPath}/bin`
384
+ return envs
385
+ }
386
+
94
387
  class Builder {
95
388
  private readonly args: string[] = []
96
389
  private readonly envs: Record<string, string> = {}
@@ -108,11 +401,7 @@ class Builder {
108
401
  private readonly config: NapiConfig,
109
402
  private readonly options: ParsedBuildOptions,
110
403
  ) {
111
- this.target = options.target
112
- ? parseTriple(options.target)
113
- : process.env.CARGO_BUILD_TARGET
114
- ? parseTriple(process.env.CARGO_BUILD_TARGET)
115
- : getSystemDefaultTarget()
404
+ this.target = resolveTarget(options.target)
116
405
  this.crateDir = parse(crate.manifest_path).dir
117
406
  this.outputDir = resolve(
118
407
  this.options.cwd,
@@ -149,6 +438,9 @@ class Builder {
149
438
  }
150
439
 
151
440
  get cdyLibName() {
441
+ if (this.options.bin) {
442
+ return
443
+ }
152
444
  return this.crate.targets.find((t) => t.crate_types.includes('cdylib'))
153
445
  ?.name
154
446
  }
@@ -164,7 +456,19 @@ class Builder {
164
456
  }
165
457
 
166
458
  build() {
167
- if (!this.cdyLibName) {
459
+ // Backstop only: `buildProject()` already validated these before running
460
+ // anything with a side effect (see the top of `buildProject`). Kept here
461
+ // so a directly constructed `Builder` cannot skip the validation.
462
+ validateCrossCompileFlags(this.options)
463
+ if (this.options.useNapiCross) {
464
+ validateNapiCrossSupport(this.target.triple)
465
+ }
466
+
467
+ if (this.options.bin) {
468
+ debug.warn(
469
+ `Building Cargo binary target ${this.binName}; the result will be an executable, not a Node.js addon.`,
470
+ )
471
+ } else if (!this.cdyLibName) {
168
472
  const warning =
169
473
  'Missing `crate-type = ["cdylib"]` in [lib] config. The build result will not be available as node addon.'
170
474
 
@@ -189,25 +493,10 @@ class Builder {
189
493
  if (!this.options.useNapiCross) {
190
494
  return this
191
495
  }
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
496
 
204
497
  try {
205
498
  const { version, download } = require('@napi-rs/cross-toolchain')
206
499
 
207
- const alias: Record<string, string> = {
208
- 's390x-unknown-linux-gnu': 's390x-ibm-linux-gnu',
209
- }
210
-
211
500
  const toolchainPath = join(
212
501
  homedir(),
213
502
  '.napi-rs',
@@ -222,66 +511,15 @@ class Builder {
222
511
  const tarArchive = download(process.arch, this.target.triple)
223
512
  tarArchive.unpack(toolchainPath)
224
513
  }
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'),
514
+ Object.assign(
515
+ this.envs,
516
+ napiCrossToolchainEnvs(toolchainPath, this.target.triple),
235
517
  )
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/'),
251
- )
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
518
  } catch (e) {
283
- debug.warn('Pick cross toolchain failed', e as Error)
284
- // ignore, do nothing
519
+ throw new Error(
520
+ `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.`,
521
+ { cause: e },
522
+ )
285
523
  }
286
524
  return this
287
525
  }
@@ -294,13 +532,21 @@ class Builder {
294
532
 
295
533
  const watch = this.options.watch
296
534
  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',
535
+ const cargoOverride = process.env.CARGO
536
+ if (
537
+ cargoOverride &&
538
+ (this.options.useCross || this.options.crossCompile)
539
+ ) {
540
+ const expectedBinary = this.options.useCross ? 'cross' : 'cargo'
541
+ const requestedFlag = this.options.useCross
542
+ ? '`--use-cross`'
543
+ : '`--cross-compile` (`-x`)'
544
+ debug.warn(
545
+ `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
546
  )
301
547
  }
302
548
  const command =
303
- process.env.CARGO ?? (this.options.useCross ? 'cross' : 'cargo')
549
+ cargoOverride ?? (this.options.useCross ? 'cross' : 'cargo')
304
550
  const buildProcess = spawn(command, this.args, {
305
551
  env: { ...process.env, ...this.envs },
306
552
  stdio: watch ? ['inherit', 'inherit', 'pipe'] : 'inherit',
@@ -345,10 +591,9 @@ class Builder {
345
591
  } else {
346
592
  debug('Use %i', 'cargo-watch')
347
593
  tryInstallCargoBinary('cargo-watch', 'watch')
348
- // yarn napi watch --target x86_64-unknown-linux-gnu [--cross-compile]
594
+ // yarn napi watch --target x86_64-unknown-linux-gnu
349
595
  // ===>
350
- // cargo watch [...] -- build --target x86_64-unknown-linux-gnu
351
- // cargo watch [...] -- zigbuild --target x86_64-unknown-linux-gnu
596
+ // cargo watch [...] -- cargo build --target x86_64-unknown-linux-gnu
352
597
  this.args.push(
353
598
  'watch',
354
599
  '--why',
@@ -502,20 +747,20 @@ class Builder {
502
747
  }
503
748
 
504
749
  private setAndroidEnv() {
750
+ // Native Android hosts and `cross` provide their own Android toolchains.
751
+ if (process.platform === 'android' || this.options.useCross) {
752
+ return
753
+ }
754
+
505
755
  const { ANDROID_NDK_LATEST_HOME } = process.env
506
756
  if (!ANDROID_NDK_LATEST_HOME) {
507
- debug.warn(
757
+ throw new Error(
508
758
  `${colors.red(
509
759
  'ANDROID_NDK_LATEST_HOME',
510
- )} environment variable is missing`,
760
+ )} environment variable is required when building an Android target from a non-Android host`,
511
761
  )
512
762
  }
513
763
 
514
- // skip cross compile setup if host is android
515
- if (process.platform === 'android') {
516
- return
517
- }
518
-
519
764
  const targetArch = this.target.arch === 'arm' ? 'armv7a' : 'aarch64'
520
765
  const targetPlatform =
521
766
  this.target.arch === 'arm' ? 'androideabi24' : 'android24'
@@ -1075,18 +1320,14 @@ export async function generateTypeDef(
1075
1320
 
1076
1321
  if (!options.noDtsHeader) {
1077
1322
  const dtsHeader = options.dtsHeader ?? options.configDtsHeader
1078
- // `dtsHeaderFile` in config > `dtsHeader` in cli flag > `dtsHeader` in config
1079
- if (options.configDtsHeaderFile) {
1323
+ const dtsHeaderFile = options.dtsHeaderFile ?? options.configDtsHeaderFile
1324
+ // An explicit API header file takes precedence over the config file;
1325
+ // either file takes precedence over inline header text.
1326
+ if (dtsHeaderFile) {
1080
1327
  try {
1081
- header = await readFileAsync(
1082
- join(options.cwd, options.configDtsHeaderFile),
1083
- 'utf-8',
1084
- )
1328
+ header = await readFileAsync(join(options.cwd, dtsHeaderFile), 'utf-8')
1085
1329
  } catch (e) {
1086
- debug.warn(
1087
- `Failed to read dts header file ${options.configDtsHeaderFile}`,
1088
- e,
1089
- )
1330
+ debug.warn(`Failed to read dts header file ${dtsHeaderFile}`, e)
1090
1331
  }
1091
1332
  } else if (dtsHeader) {
1092
1333
  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
+ })