@napi-rs/cli 3.7.4 → 3.8.0

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 (43) hide show
  1. package/README.md +33 -0
  2. package/cli.mjs +9 -12
  3. package/dist/cli.js +9248 -1311
  4. package/dist/index.cjs +9268 -1331
  5. package/dist/index.d.cts +175 -157
  6. package/dist/index.d.ts +175 -157
  7. package/dist/index.js +9248 -1311
  8. package/docs/artifacts.md +33 -0
  9. package/docs/build.md +58 -0
  10. package/docs/create-npm-dirs.md +32 -0
  11. package/docs/new.md +39 -0
  12. package/docs/pre-publish.md +37 -0
  13. package/docs/rename.md +37 -0
  14. package/docs/universalize.md +31 -0
  15. package/docs/version.md +31 -0
  16. package/docs/wasi.md +161 -0
  17. package/package.json +11 -10
  18. package/src/api/__tests__/__snapshots__/templates.spec.ts.md +1019 -114
  19. package/src/api/__tests__/__snapshots__/templates.spec.ts.snap +0 -0
  20. package/src/api/__tests__/artifacts.spec.ts +17 -0
  21. package/src/api/__tests__/create-npm-dirs.spec.ts +14 -15
  22. package/src/api/__tests__/new.spec.ts +41 -5
  23. package/src/api/artifacts.ts +934 -108
  24. package/src/api/build.ts +1317 -188
  25. package/src/api/create-npm-dirs.ts +472 -101
  26. package/src/api/new.ts +354 -35
  27. package/src/api/pre-publish.ts +2845 -99
  28. package/src/api/rename.ts +1638 -124
  29. package/src/api/templates/js-binding.ts +216 -38
  30. package/src/api/templates/load-wasi-template.ts +1641 -111
  31. package/src/api/templates/wasi-worker-template.ts +32 -3
  32. package/src/commands/rename.ts +8 -3
  33. package/src/def/artifacts.ts +2 -2
  34. package/src/utils/__tests__/__snapshots__/target.spec.ts.md +2 -2
  35. package/src/utils/__tests__/__snapshots__/target.spec.ts.snap +0 -0
  36. package/src/utils/__tests__/misc.spec.ts +124 -3
  37. package/src/utils/__tests__/version.spec.ts +47 -1
  38. package/src/utils/config.ts +15 -2
  39. package/src/utils/metadata.ts +155 -47
  40. package/src/utils/misc.ts +6079 -0
  41. package/src/utils/target.ts +66 -8
  42. package/src/utils/typegen.ts +1223 -63
  43. package/src/utils/version.ts +96 -0
package/src/api/new.ts CHANGED
@@ -13,19 +13,24 @@ import {
13
13
  } from '../def/new.js'
14
14
  import {
15
15
  AVAILABLE_TARGETS,
16
+ CLI_VERSION,
16
17
  debugFactory,
17
18
  DEFAULT_TARGETS,
19
+ getWasiTarget,
18
20
  mkdirAsync,
19
21
  parseTriple,
20
22
  readdirAsync,
21
23
  statAsync,
24
+ wasiTargetHasThreads,
22
25
  type SupportedPackageManager,
23
26
  } from '../utils/index.js'
24
27
  import {
25
28
  napiEngineRequirement,
26
29
  SUPPORTED_NAPI_VERSIONS,
30
+ restrictWasiNodeEngine,
27
31
  } from '../utils/version.js'
28
32
  import { renameProject } from './rename.js'
33
+ import { createCjsBinding } from './templates/index.js'
29
34
 
30
35
  // Template imports removed as we're now using external templates
31
36
 
@@ -38,7 +43,117 @@ const TEMPLATE_REPOS = {
38
43
  pnpm: 'https://github.com/napi-rs/package-template-pnpm',
39
44
  } as const
40
45
 
41
- const WASI_TARGET = 'wasm32-wasip1-threads'
46
+ const TEMPLATE_LOCKFILES: Record<SupportedPackageManager, string> = {
47
+ yarn: 'yarn.lock',
48
+ pnpm: 'pnpm-lock.yaml',
49
+ }
50
+ const WASI_CI_ARTIFACT_PATTERNS = [
51
+ 'index.js',
52
+ 'browser.js',
53
+ '*.wasi*.cjs',
54
+ '*.wasi*.d.cts',
55
+ '*.wasi*-browser.js',
56
+ '*.wasi*-deferred.js',
57
+ '*.wasi*-deferred.d.ts',
58
+ 'wasi-worker*.mjs',
59
+ ]
60
+ // Exported for tests.
61
+ export const GENERATED_WASI_BINDING =
62
+ /(?:\.(?:wasi|wasip\d+)(?:-browser|-deferred)?\.(?:cjs|js|d\.[cm]?ts)|wasi-worker(?:-browser)?\.mjs)/
63
+ const TYPE_DEF_FILE = /\.d\.[cm]?ts$/
64
+ const GLOB_PATTERN = /[*?[\]{}]/
65
+
66
+ function createWasiBrowserEntry(
67
+ packageName: string,
68
+ platformArchABI: string,
69
+ enableTypeDef: boolean,
70
+ ) {
71
+ const packageSpecifier = `${packageName}-${platformArchABI}`
72
+ return (
73
+ `export * from '${packageSpecifier}'\n` +
74
+ (enableTypeDef ? '' : `export { default } from '${packageSpecifier}'\n`)
75
+ )
76
+ }
77
+
78
+ function stripTypeConditions(
79
+ value: unknown,
80
+ typeDefPaths: Set<string>,
81
+ ): unknown {
82
+ if (Array.isArray(value)) {
83
+ const entries = value
84
+ .map((entry) => stripTypeConditions(entry, typeDefPaths))
85
+ .filter((entry) => entry !== undefined)
86
+ return entries.length > 0 ? entries : undefined
87
+ }
88
+ if (typeof value !== 'object' || value === null) {
89
+ return value
90
+ }
91
+
92
+ const result: Record<string, unknown> = {}
93
+ for (const [key, entry] of Object.entries(value)) {
94
+ if (key === 'types') {
95
+ if (typeof entry === 'string') {
96
+ typeDefPaths.add(entry)
97
+ }
98
+ continue
99
+ }
100
+ const stripped = stripTypeConditions(entry, typeDefPaths)
101
+ if (stripped !== undefined) {
102
+ result[key] = stripped
103
+ }
104
+ }
105
+ return Object.keys(result).length > 0 ? result : undefined
106
+ }
107
+
108
+ async function removeTypeDefOutput(
109
+ packageJson: Record<string, any>,
110
+ packageDir: string,
111
+ ) {
112
+ const typeDefPaths = new Set<string>(['index.d.ts'])
113
+ for (const field of ['types', 'typings']) {
114
+ if (typeof packageJson[field] === 'string') {
115
+ typeDefPaths.add(packageJson[field])
116
+ }
117
+ delete packageJson[field]
118
+ }
119
+ delete packageJson.typesVersions
120
+
121
+ if (packageJson.exports !== undefined) {
122
+ const exports = stripTypeConditions(packageJson.exports, typeDefPaths)
123
+ if (exports === undefined) {
124
+ delete packageJson.exports
125
+ } else {
126
+ packageJson.exports = exports
127
+ }
128
+ }
129
+
130
+ if (Array.isArray(packageJson.files)) {
131
+ packageJson.files = packageJson.files.filter((file: unknown) => {
132
+ if (typeof file !== 'string' || !TYPE_DEF_FILE.test(file)) {
133
+ return true
134
+ }
135
+ typeDefPaths.add(file)
136
+ return false
137
+ })
138
+ }
139
+
140
+ for (const typeDefPath of typeDefPaths) {
141
+ const relativePath = typeDefPath.replace(/^\.\//, '')
142
+ if (!TYPE_DEF_FILE.test(relativePath) || GLOB_PATTERN.test(relativePath)) {
143
+ continue
144
+ }
145
+ const absolutePath = path.resolve(packageDir, relativePath)
146
+ const relativeToPackage = path.relative(packageDir, absolutePath)
147
+ if (
148
+ relativeToPackage === '' ||
149
+ relativeToPackage.startsWith(`..${path.sep}`) ||
150
+ path.isAbsolute(relativeToPackage)
151
+ ) {
152
+ continue
153
+ }
154
+ await fs.rm(absolutePath, { force: true })
155
+ }
156
+ }
42
157
 
43
158
  async function checkGitCommand(): Promise<boolean> {
44
159
  return new Promise<boolean>((resolve) => {
@@ -109,7 +224,7 @@ async function downloadTemplate(
109
224
  async function copyDirectory(
110
225
  src: string,
111
226
  dest: string,
112
- includeWasiBindings: boolean,
227
+ hasWasiTargets: boolean,
113
228
  ): Promise<void> {
114
229
  await mkdirAsync(dest, { recursive: true })
115
230
  const entries = await fs.readdir(src, { withFileTypes: true })
@@ -124,15 +239,12 @@ async function copyDirectory(
124
239
  }
125
240
 
126
241
  if (entry.isDirectory()) {
127
- await copyDirectory(srcPath, destPath, includeWasiBindings)
242
+ await copyDirectory(srcPath, destPath, hasWasiTargets)
128
243
  } else {
129
244
  if (
130
- !includeWasiBindings &&
131
- (entry.name.endsWith('.wasi-browser.js') ||
132
- entry.name.endsWith('.wasi.cjs') ||
133
- entry.name.endsWith('wasi-worker-browser.mjs') ||
134
- entry.name.endsWith('wasi-worker.mjs') ||
135
- entry.name === 'browser.js')
245
+ entry.name === 'browser.js' ||
246
+ GENERATED_WASI_BINDING.test(entry.name) ||
247
+ (hasWasiTargets && entry.name === 'index.js')
136
248
  ) {
137
249
  continue
138
250
  }
@@ -144,19 +256,83 @@ async function copyDirectory(
144
256
  async function filterTargetsInPackageJson(
145
257
  filePath: string,
146
258
  enabledTargets: string[],
259
+ enableTypeDef: boolean,
260
+ packageManager: SupportedPackageManager,
147
261
  ): Promise<void> {
148
262
  const content = await fs.readFile(filePath, 'utf-8')
149
263
  const packageJson = JSON.parse(content)
150
- const includeWasiBindings = enabledTargets.includes(WASI_TARGET)
264
+ const wasiTargets = enabledTargets
265
+ .map(parseTriple)
266
+ .filter((target) => target.platform === 'wasi')
267
+ const includeWasiBindings = wasiTargets.length > 0
268
+ const includeThreadlessWasi = wasiTargets.some(
269
+ (target) => !wasiTargetHasThreads(target),
270
+ )
151
271
 
152
- // Filter napi.targets
153
- if (packageJson.napi?.targets) {
154
- packageJson.napi.targets = packageJson.napi.targets.filter(
155
- (target: string) => enabledTargets.includes(target),
272
+ // The external templates may not yet list newly supported targets. The
273
+ // requested target set is authoritative rather than a filter over the
274
+ // template's current contents.
275
+ packageJson.napi ??= {}
276
+ packageJson.napi.targets = enabledTargets
277
+
278
+ if (includeThreadlessWasi) {
279
+ packageJson.devDependencies ??= {}
280
+ packageJson.devDependencies['@napi-rs/cli'] = `^${CLI_VERSION}`
281
+ // Template lockfiles can pin an older CLI that predates this target even
282
+ // when package.json's range is updated. The first install must resolve a
283
+ // lockfile from the generated manifest.
284
+ await fs.rm(
285
+ path.join(path.dirname(filePath), TEMPLATE_LOCKFILES[packageManager]),
286
+ { force: true },
156
287
  )
157
288
  }
158
289
 
159
- if (!includeWasiBindings) {
290
+ if (!enableTypeDef) {
291
+ await removeTypeDefOutput(packageJson, path.dirname(filePath))
292
+ }
293
+
294
+ if (includeWasiBindings) {
295
+ packageJson.browser = 'browser.js'
296
+ packageJson.files ??= []
297
+ if (!packageJson.files.includes('browser.js')) {
298
+ packageJson.files.push('browser.js')
299
+ }
300
+ const browserTarget =
301
+ wasiTargets.find(
302
+ (target) => getWasiTarget(target)?.flavor === 'single',
303
+ ) ?? wasiTargets[0]
304
+ await fs.writeFile(
305
+ path.join(path.dirname(filePath), 'browser.js'),
306
+ createWasiBrowserEntry(
307
+ packageJson.name,
308
+ browserTarget.platformArchABI,
309
+ enableTypeDef,
310
+ ),
311
+ )
312
+ if (!enableTypeDef) {
313
+ const wasiFlavors = [
314
+ ...wasiTargets.filter(wasiTargetHasThreads),
315
+ ...wasiTargets.filter((target) => !wasiTargetHasThreads(target)),
316
+ ].map((target) => target.platformArchABI)
317
+ packageJson.main = 'index.js'
318
+ if (
319
+ !packageJson.files.includes('index.js') &&
320
+ !packageJson.files.includes('./index.js')
321
+ ) {
322
+ packageJson.files.push('index.js')
323
+ }
324
+ await fs.writeFile(
325
+ path.join(path.dirname(filePath), 'index.js'),
326
+ createCjsBinding(
327
+ packageJson.napi.binaryName,
328
+ packageJson.napi.packageName ?? packageJson.name,
329
+ [],
330
+ packageJson.version,
331
+ wasiFlavors,
332
+ ),
333
+ )
334
+ }
335
+ } else {
160
336
  if (
161
337
  packageJson.browser === 'browser.js' ||
162
338
  packageJson.browser === './browser.js'
@@ -172,6 +348,49 @@ async function filterTargetsInPackageJson(
172
348
  }
173
349
 
174
350
  await fs.writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n')
351
+ await updateGeneratedWasiAttributes(
352
+ path.join(path.dirname(filePath), '.gitattributes'),
353
+ packageJson.napi.binaryName,
354
+ wasiTargets,
355
+ )
356
+ }
357
+
358
+ async function updateGeneratedWasiAttributes(
359
+ filePath: string,
360
+ binaryName: string,
361
+ wasiTargets: ReturnType<typeof parseTriple>[],
362
+ ) {
363
+ if (!existsSync(filePath)) {
364
+ return
365
+ }
366
+
367
+ const lines = (await fs.readFile(filePath, 'utf8'))
368
+ .split('\n')
369
+ .filter((line) => !GENERATED_WASI_BINDING.test(line))
370
+ const generatedFiles = new Set<string>()
371
+ for (const target of wasiTargets) {
372
+ const suffix = target.platformArchABI.replace(/^wasm32-/, '')
373
+ generatedFiles.add(`${binaryName}.${suffix}.cjs`)
374
+ generatedFiles.add(`${binaryName}.${suffix}.d.cts`)
375
+ generatedFiles.add(`${binaryName}.${suffix}-browser.js`)
376
+ if (getWasiTarget(target)?.flavor === 'threads') {
377
+ generatedFiles.add('wasi-worker.mjs')
378
+ generatedFiles.add('wasi-worker-browser.mjs')
379
+ } else {
380
+ generatedFiles.add(`${binaryName}.${suffix}-deferred.js`)
381
+ generatedFiles.add(`${binaryName}.${suffix}-deferred.d.ts`)
382
+ }
383
+ }
384
+ if (generatedFiles.size > 0) {
385
+ while (lines[lines.length - 1] === '') {
386
+ lines.pop()
387
+ }
388
+ lines.push(
389
+ '',
390
+ ...[...generatedFiles].map((file) => `${file} linguist-detectable=false`),
391
+ )
392
+ }
393
+ await fs.writeFile(filePath, `${lines.join('\n')}\n`)
175
394
  }
176
395
 
177
396
  async function updateCargoTomlTypeDef(
@@ -289,16 +508,70 @@ async function filterTargetsInGithubActions(
289
508
  const platform = parseTriple(target).platform
290
509
  return platform === 'darwin' || platform === 'win32'
291
510
  })
511
+ const wasiTargets = enabledTargets.filter((target) => getWasiTarget(target))
292
512
 
293
513
  // Filter the matrix configurations in the build job
294
514
  if (yaml?.jobs?.build?.strategy?.matrix?.settings) {
295
- yaml.jobs.build.strategy.matrix.settings =
296
- yaml.jobs.build.strategy.matrix.settings.filter((setting: any) => {
297
- if (setting.target) {
298
- return enabledTargets.includes(setting.target)
299
- }
300
- return true
301
- })
515
+ const settings = yaml.jobs.build.strategy.matrix.settings
516
+ const wasiTemplate = settings.find(
517
+ (setting: any) => setting.target && getWasiTarget(setting.target),
518
+ )
519
+ const wasiTemplateTarget =
520
+ typeof wasiTemplate?.target === 'string' ? wasiTemplate.target : undefined
521
+ const wasiTargetComparison = wasiTemplateTarget
522
+ ? new RegExp(
523
+ `matrix\\.settings\\.target\\s*(==|!=)\\s*(['"])${wasiTemplateTarget.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\2`,
524
+ )
525
+ : undefined
526
+ const filteredSettings = settings.filter((setting: any) => {
527
+ if (setting.target && getWasiTarget(setting.target)) {
528
+ return false
529
+ }
530
+ if (setting.target) {
531
+ return enabledTargets.includes(setting.target)
532
+ }
533
+ return true
534
+ })
535
+ if (wasiTargets.length > 0 && !wasiTemplate) {
536
+ throw new Error('Template CI is missing a WASI build matrix entry')
537
+ }
538
+ for (const target of wasiTargets) {
539
+ const setting = { ...wasiTemplate }
540
+ const templateTarget = setting.target
541
+ setting.target = target
542
+ if (typeof setting.build === 'string') {
543
+ setting.build = setting.build.replaceAll(templateTarget, target)
544
+ }
545
+ filteredSettings.push(setting)
546
+ }
547
+ yaml.jobs.build.strategy.matrix.settings = filteredSettings
548
+
549
+ for (const step of yaml.jobs.build.steps ?? []) {
550
+ const operator =
551
+ typeof step.if === 'string' && wasiTargetComparison
552
+ ? step.if.match(wasiTargetComparison)?.[1]
553
+ : undefined
554
+ if (operator === '!=') {
555
+ step.if = "${{ !startsWith(matrix.settings.target, 'wasm32-') }}"
556
+ } else if (operator === '==') {
557
+ step.if = "${{ startsWith(matrix.settings.target, 'wasm32-') }}"
558
+ }
559
+ if (
560
+ wasiTargets.length > 0 &&
561
+ typeof step.uses === 'string' &&
562
+ step.uses.startsWith('actions/upload-artifact@') &&
563
+ typeof step.with?.path === 'string' &&
564
+ step.with.path.includes('.wasm')
565
+ ) {
566
+ const existingPatterns = step.with.path
567
+ .split('\n')
568
+ .map((pattern: string) => pattern.trim())
569
+ .filter(Boolean)
570
+ step.with.path = [
571
+ ...new Set([...existingPatterns, ...WASI_CI_ARTIFACT_PATTERNS]),
572
+ ].join('\n')
573
+ }
574
+ }
302
575
  }
303
576
 
304
577
  const jobsToRemove: string[] = []
@@ -342,8 +615,38 @@ async function filterTargetsInGithubActions(
342
615
  }
343
616
  }
344
617
 
345
- if (!enabledTargets.includes(WASI_TARGET)) {
618
+ if (wasiTargets.length === 0) {
346
619
  jobsToRemove.push('test-wasi')
620
+ } else if (yaml.jobs?.['test-wasi']) {
621
+ const wasiJob = yaml.jobs['test-wasi']
622
+ if (wasiTargets.length > 1) {
623
+ wasiJob.strategy = {
624
+ 'fail-fast': false,
625
+ matrix: {
626
+ target: wasiTargets,
627
+ },
628
+ }
629
+ wasiJob.name = 'Test WASI target - ${{ matrix.target }}'
630
+ }
631
+ const downloadStep = wasiJob.steps?.find(
632
+ (step: any) =>
633
+ typeof step.uses === 'string' &&
634
+ step.uses.startsWith('actions/download-artifact@'),
635
+ )
636
+ if (downloadStep?.with) {
637
+ downloadStep.with.name =
638
+ wasiTargets.length === 1
639
+ ? `bindings-${wasiTargets[0]}`
640
+ : 'bindings-${{ matrix.target }}'
641
+ }
642
+ for (const step of wasiJob.steps ?? []) {
643
+ if (
644
+ step.env &&
645
+ Object.prototype.hasOwnProperty.call(step.env, 'NAPI_RS_FORCE_WASI')
646
+ ) {
647
+ step.env.NAPI_RS_FORCE_WASI = 'true'
648
+ }
649
+ }
347
650
  }
348
651
 
349
652
  if (!enabledTargets.includes('x86_64-unknown-freebsd')) {
@@ -423,17 +726,21 @@ function processOptions(options: RawNewOptions) {
423
726
  throw new Error('At least one target must be enabled')
424
727
  }
425
728
  }
426
- if (
427
- options.targets.some((target) => target === 'wasm32-wasi-preview1-threads')
428
- ) {
429
- const out = execSync(`rustup target list`, {
430
- encoding: 'utf8',
431
- })
432
- if (out.includes(WASI_TARGET)) {
433
- options.targets = options.targets.map((target) =>
434
- target === 'wasm32-wasi-preview1-threads' ? WASI_TARGET : target,
729
+ const requestedTargets = options.targets.map((target) => ({
730
+ target,
731
+ parsed: parseTriple(target),
732
+ }))
733
+ options.targets = requestedTargets.map(({ parsed }) => parsed.triple)
734
+ const outputTargets = new Map<string, string>()
735
+ for (const { target, parsed } of requestedTargets) {
736
+ const { platformArchABI } = parsed
737
+ const previous = outputTargets.get(platformArchABI)
738
+ if (previous) {
739
+ throw new Error(
740
+ `Targets ${previous} and ${target} produce the same ${platformArchABI} artifact set. Choose one target spelling.`,
435
741
  )
436
742
  }
743
+ outputTargets.set(platformArchABI, target)
437
744
  }
438
745
 
439
746
  return applyDefaultNewOptions(options) as NewOptions
@@ -471,7 +778,7 @@ export async function newProject(userOptions: RawNewOptions) {
471
778
  await copyDirectory(
472
779
  templatePath,
473
780
  options.path,
474
- options.targets.includes(WASI_TARGET),
781
+ options.targets.some((target) => getWasiTarget(target) !== undefined),
475
782
  )
476
783
 
477
784
  // Rename project using the rename API
@@ -493,7 +800,12 @@ export async function newProject(userOptions: RawNewOptions) {
493
800
  // Filter targets in package.json
494
801
  const packageJsonPath = path.join(options.path, 'package.json')
495
802
  if (existsSync(packageJsonPath)) {
496
- await filterTargetsInPackageJson(packageJsonPath, options.targets)
803
+ await filterTargetsInPackageJson(
804
+ packageJsonPath,
805
+ options.targets,
806
+ options.enableTypeDef,
807
+ packageManager,
808
+ )
497
809
  }
498
810
 
499
811
  // Filter targets in GitHub Actions CI
@@ -519,7 +831,14 @@ export async function newProject(userOptions: RawNewOptions) {
519
831
  if (!pkgJson.engines) {
520
832
  pkgJson.engines = {}
521
833
  }
522
- pkgJson.engines.node = napiEngineRequirement(options.minNodeApiVersion)
834
+ const nodeEngineRequirement = napiEngineRequirement(
835
+ options.minNodeApiVersion,
836
+ )
837
+ pkgJson.engines.node = options.targets.every(
838
+ (target) => parseTriple(target).platform === 'wasi',
839
+ )
840
+ ? restrictWasiNodeEngine(nodeEngineRequirement)
841
+ : nodeEngineRequirement
523
842
 
524
843
  // Update license if different from template
525
844
  if (options.license && pkgJson.license !== options.license) {