@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
@@ -6,7 +6,13 @@ import { parentPort, Worker } from "node:worker_threads";
6
6
 
7
7
  const require = createRequire(import.meta.url);
8
8
 
9
- const { instantiateNapiModuleSync, MessageHandler, getDefaultContext } = require("@napi-rs/wasm-runtime");
9
+ const {
10
+ instantiateNapiModuleSync,
11
+ MessageHandler,
12
+ getDefaultContext,
13
+ emnapiAsyncWorkPlugin,
14
+ emnapiTSFNPlugin,
15
+ } = require("@napi-rs/wasm-runtime");
10
16
 
11
17
  if (parentPort) {
12
18
  parentPort.on("message", (data) => {
@@ -46,6 +52,11 @@ const handler = new MessageHandler({
46
52
  childThread: true,
47
53
  wasi,
48
54
  context: emnapiContext,
55
+ // The wasm links a "basic" emnapi archive (no C async-work /
56
+ // threadsafe-function implementations), so every thread that
57
+ // instantiates it must provide the JavaScript implementations
58
+ // through the emnapi plugins.
59
+ plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin],
49
60
  overwriteImports(importObject) {
50
61
  importObject.env = {
51
62
  ...importObject.env,
@@ -68,11 +79,24 @@ export const createWasiBrowserWorkerBinding = (
68
79
  errorEvent: boolean,
69
80
  ) => {
70
81
  const fsImport = fs
71
- ? `import { instantiateNapiModuleSync, MessageHandler, WASI, createFsProxy } from '@napi-rs/wasm-runtime'
82
+ ? `import {
83
+ instantiateNapiModuleSync,
84
+ MessageHandler,
85
+ WASI,
86
+ createFsProxy,
87
+ emnapiAsyncWorkPlugin,
88
+ emnapiTSFNPlugin,
89
+ } from '@napi-rs/wasm-runtime'
72
90
  import { memfsExported as __memfsExported } from '@napi-rs/wasm-runtime/fs'
73
91
 
74
92
  const fs = createFsProxy(__memfsExported)`
75
- : `import { instantiateNapiModuleSync, MessageHandler, WASI } from '@napi-rs/wasm-runtime'`
93
+ : `import {
94
+ instantiateNapiModuleSync,
95
+ MessageHandler,
96
+ WASI,
97
+ emnapiAsyncWorkPlugin,
98
+ emnapiTSFNPlugin,
99
+ } from '@napi-rs/wasm-runtime'`
76
100
  const errorOutputsAppend = errorEvent
77
101
  ? `\n errorOutputs.push([...arguments])`
78
102
  : ''
@@ -118,6 +142,11 @@ const handler = new MessageHandler({
118
142
  return instantiateNapiModuleSync(wasmModule, {
119
143
  childThread: true,
120
144
  wasi,
145
+ // The wasm links a "basic" emnapi archive (no C async-work /
146
+ // threadsafe-function implementations), so every thread that
147
+ // instantiates it must provide the JavaScript implementations
148
+ // through the emnapi plugins.
149
+ plugins: [emnapiAsyncWorkPlugin, emnapiTSFNPlugin],
121
150
  overwriteImports(importObject) {
122
151
  importObject.env = {
123
152
  ...importObject.env,
@@ -6,14 +6,19 @@ import { BaseRenameCommand } from '../def/rename.js'
6
6
  export class RenameCommand extends BaseRenameCommand {
7
7
  async execute() {
8
8
  const options = this.getOptions()
9
- if (!options.name) {
9
+ const hasExplicitRename = [
10
+ options.name,
11
+ options.binaryName,
12
+ options.packageName,
13
+ options.repository,
14
+ options.description,
15
+ ].some((value) => value !== undefined)
16
+ if (!hasExplicitRename) {
10
17
  const name = await input({
11
18
  message: `Enter the new package name in the package.json`,
12
19
  required: true,
13
20
  })
14
21
  options.name = name
15
- }
16
- if (!options.binaryName) {
17
22
  const binaryName = await input({
18
23
  message: `Enter the new binary name`,
19
24
  required: true,
@@ -34,7 +34,7 @@ export abstract class BaseArtifactsCommand extends Command {
34
34
 
35
35
  buildOutputDir?: string = Option.String('--build-output-dir', {
36
36
  description:
37
- 'Path to the build output dir, only needed when targets contains `wasm32-wasi-*`',
37
+ 'Path to the build output dir, only needed when targets contain a WASI target',
38
38
  })
39
39
 
40
40
  getOptions() {
@@ -82,7 +82,7 @@ export interface ArtifactsOptions {
82
82
  */
83
83
  npmDir?: string
84
84
  /**
85
- * Path to the build output dir, only needed when targets contains `wasm32-wasi-*`
85
+ * Path to the build output dir, only needed when targets contain a WASI target
86
86
  */
87
87
  buildOutputDir?: string
88
88
  }
@@ -167,8 +167,8 @@ Generated by [AVA](https://avajs.dev).
167
167
  abi: 'wasi',
168
168
  arch: 'wasm32',
169
169
  platform: 'wasi',
170
- platformArchABI: 'wasm32-wasi',
171
- triple: 'wasm32-wasi-preview1-threads',
170
+ platformArchABI: 'wasm32-wasip1',
171
+ triple: 'wasm32-wasip1',
172
172
  },
173
173
  {
174
174
  abi: 'wasi',
@@ -1,11 +1,29 @@
1
- import { existsSync } from 'node:fs'
2
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
1
+ import { existsSync, type BigIntStats } from 'node:fs'
2
+ import {
3
+ lstat,
4
+ mkdtemp,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ writeFile,
10
+ } from 'node:fs/promises'
3
11
  import { tmpdir } from 'node:os'
4
12
  import { join } from 'node:path'
5
13
 
6
14
  import ava, { type TestFn } from 'ava'
7
15
 
8
- import { updatePackageJson } from '../misc.js'
16
+ import {
17
+ retireFailedSnapshotLeftover,
18
+ snapshotLeftoverIsTransactionOwned,
19
+ statIdentitiesMatch,
20
+ updatePackageJson,
21
+ } from '../misc.js'
22
+
23
+ async function fileIdentityStrings(path: string) {
24
+ const stats = await lstat(path, { bigint: true })
25
+ return { dev: String(stats.dev), ino: String(stats.ino) }
26
+ }
9
27
 
10
28
  const test = ava as TestFn<{
11
29
  tmpDir: string
@@ -54,3 +72,106 @@ test('updatePackageJson merges nested objects instead of overwriting them', asyn
54
72
  '@napi-rs/fixture-darwin-arm64': '1.0.1',
55
73
  })
56
74
  })
75
+
76
+ test('statIdentitiesMatch distinguishes inodes that collide as lossy Numbers', (t) => {
77
+ const dev = 1n
78
+ const ino = 2n ** 53n
79
+ const owned = { dev, ino }
80
+ const successor = { dev, ino: ino + 1n }
81
+
82
+ // The defect being guarded against: past Number.MAX_SAFE_INTEGER two
83
+ // distinct 64-bit identifiers collapse onto the same JS double, so a
84
+ // numeric Stats.dev/ino comparison cannot tell these files apart.
85
+ t.is(Number(owned.ino), Number(successor.ino))
86
+
87
+ t.false(statIdentitiesMatch(owned, successor))
88
+ t.false(statIdentitiesMatch({ dev: dev + 1n, ino }, owned))
89
+ t.true(statIdentitiesMatch(owned, { dev, ino }))
90
+ t.false(statIdentitiesMatch(owned, undefined))
91
+ })
92
+
93
+ test('snapshotLeftoverIsTransactionOwned rejects Number-colliding successors', (t) => {
94
+ const identity = { dev: '1', ino: String(2n ** 53n) }
95
+ const ownedStats = {
96
+ isFile: () => true,
97
+ dev: 1n,
98
+ ino: 2n ** 53n,
99
+ } as unknown as BigIntStats
100
+ const successorStats = {
101
+ isFile: () => true,
102
+ dev: 1n,
103
+ ino: 2n ** 53n + 1n,
104
+ } as unknown as BigIntStats
105
+
106
+ t.true(snapshotLeftoverIsTransactionOwned(ownedStats, identity))
107
+ t.false(snapshotLeftoverIsTransactionOwned(successorStats, identity))
108
+ t.false(snapshotLeftoverIsTransactionOwned(undefined, identity))
109
+ })
110
+
111
+ test('retireFailedSnapshotLeftover removes the transaction-owned inode', async (t) => {
112
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
113
+ await writeFile(destination, 'partial snapshot')
114
+ const identity = await fileIdentityStrings(destination)
115
+
116
+ const result = await retireFailedSnapshotLeftover(destination, identity)
117
+
118
+ t.deepEqual(result, { outcome: 'removed' })
119
+ t.false(existsSync(destination))
120
+ t.deepEqual(await readdir(t.context.tmpDir), [])
121
+ })
122
+
123
+ test('retireFailedSnapshotLeftover reports a missing leftover', async (t) => {
124
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
125
+ await writeFile(destination, 'partial snapshot')
126
+ const identity = await fileIdentityStrings(destination)
127
+ await rm(destination)
128
+
129
+ const result = await retireFailedSnapshotLeftover(destination, identity)
130
+
131
+ t.deepEqual(result, { outcome: 'missing' })
132
+ t.deepEqual(await readdir(t.context.tmpDir), [])
133
+ })
134
+
135
+ test('retireFailedSnapshotLeftover keeps a pre-existing non-owned successor', async (t) => {
136
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
137
+ await writeFile(destination, 'partial snapshot')
138
+ const identity = await fileIdentityStrings(destination)
139
+
140
+ // Replace the owned inode with a distinct one before cleanup runs. The
141
+ // successor is created as a sibling first so it deterministically has a
142
+ // different inode, then atomically renamed over the destination.
143
+ const successor = join(t.context.tmpDir, 'successor.tmp')
144
+ await writeFile(successor, 'successor content')
145
+ await rename(successor, destination)
146
+
147
+ const result = await retireFailedSnapshotLeftover(destination, identity)
148
+
149
+ t.deepEqual(result, { outcome: 'kept' })
150
+ t.is(await readFile(destination, 'utf8'), 'successor content')
151
+ t.deepEqual(await readdir(t.context.tmpDir), ['leftover.tmp'])
152
+ })
153
+
154
+ test('retireFailedSnapshotLeftover restores a successor swapped in during the race window', async (t) => {
155
+ const destination = join(t.context.tmpDir, 'leftover.tmp')
156
+ await writeFile(destination, 'partial snapshot')
157
+ const identity = await fileIdentityStrings(destination)
158
+
159
+ const successor = join(t.context.tmpDir, 'successor.tmp')
160
+ await writeFile(successor, 'successor content')
161
+
162
+ // Swap the successor onto the pathname after the ownership pre-check and
163
+ // before the retirement rename — the exact interval in which the previous
164
+ // lstat-then-unlink cleanup would have deleted a file the transaction never
165
+ // owned.
166
+ const result = await retireFailedSnapshotLeftover(
167
+ destination,
168
+ identity,
169
+ async () => {
170
+ await rename(successor, destination)
171
+ },
172
+ )
173
+
174
+ t.deepEqual(result, { outcome: 'kept' })
175
+ t.is(await readFile(destination, 'utf8'), 'successor content')
176
+ t.deepEqual(await readdir(t.context.tmpDir), ['leftover.tmp'])
177
+ })
@@ -1,7 +1,53 @@
1
1
  import test from 'ava'
2
2
 
3
- import { napiEngineRequirement, SUPPORTED_NAPI_VERSIONS } from '../version.js'
3
+ import {
4
+ MINIMUM_WASI_NODE_VERSION,
5
+ napiEngineRequirement,
6
+ restrictWasiNodeEngine,
7
+ SUPPORTED_NAPI_VERSIONS,
8
+ } from '../version.js'
4
9
 
5
10
  test('should generate correct napi engine requirement', (t) => {
6
11
  t.snapshot(SUPPORTED_NAPI_VERSIONS.map(napiEngineRequirement))
7
12
  })
13
+
14
+ test('should keep node engine ranges inside the WASI floor', (t) => {
15
+ t.is(restrictWasiNodeEngine('^22.14.0'), '^22.14.0')
16
+ t.is(restrictWasiNodeEngine('>=24'), '>=24')
17
+ })
18
+
19
+ test('should return the WASI floor for superset node engine ranges', (t) => {
20
+ t.is(restrictWasiNodeEngine('>=18'), MINIMUM_WASI_NODE_VERSION)
21
+ t.is(restrictWasiNodeEngine('>=12 <14 || >=18'), MINIMUM_WASI_NODE_VERSION)
22
+ })
23
+
24
+ test('should preserve partial intersections with the WASI floor', (t) => {
25
+ t.is(restrictWasiNodeEngine('>=22'), '>=22.13.0 <23.0.0-0 || >=23.5.0')
26
+ t.is(restrictWasiNodeEngine('<21'), '>=20.19.0 <21.0.0-0')
27
+ })
28
+
29
+ test('should reject node engine ranges disjoint from the WASI floor', (t) => {
30
+ for (const nodeRange of ['>=21 <22', '<20', '13.0.0', '21.0.0']) {
31
+ const error = t.throws(() => restrictWasiNodeEngine(nodeRange))
32
+ t.true(
33
+ error.message.includes(`"${nodeRange}"`),
34
+ `error should name the rejected range ${nodeRange}`,
35
+ )
36
+ t.true(
37
+ error.message.includes(`"${MINIMUM_WASI_NODE_VERSION}"`),
38
+ 'error should name the supported WASI versions',
39
+ )
40
+ }
41
+ })
42
+
43
+ test('should fall back to the WASI floor for malformed node engine ranges', (t) => {
44
+ t.is(restrictWasiNodeEngine('not-a-range'), MINIMUM_WASI_NODE_VERSION)
45
+ })
46
+
47
+ test('napi engine requirements always intersect the WASI floor', (t) => {
48
+ for (const napiVersion of SUPPORTED_NAPI_VERSIONS) {
49
+ t.notThrows(() =>
50
+ restrictWasiNodeEngine(napiEngineRequirement(napiVersion)),
51
+ )
52
+ }
53
+ })
@@ -64,7 +64,7 @@ export interface UserNapiConfig {
64
64
  wasm?: {
65
65
  /**
66
66
  * https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Memory
67
- * @default 4000 pages (256MiB)
67
+ * @default 4000 pages (250 MiB), or 1024 pages (64 MiB) for the deferred workerd loader
68
68
  */
69
69
  initialMemory?: number
70
70
  /**
@@ -151,6 +151,7 @@ export interface CommonPackageJsonFields {
151
151
 
152
152
  dependencies?: Record<string, string>
153
153
  devDependencies?: Record<string, string>
154
+ optionalDependencies?: Record<string, string>
154
155
 
155
156
  ava?: {
156
157
  timeout?: string
@@ -265,7 +266,19 @@ export async function readNapiConfig(
265
266
  throw new Error(`Duplicate targets are not allowed: ${duplicateTarget}`)
266
267
  }
267
268
 
268
- napiConfig.targets = targets.map(parseTriple)
269
+ const parsedTargets = targets.map(parseTriple)
270
+ const outputTargets = new Map<string, string>()
271
+ for (const [index, target] of parsedTargets.entries()) {
272
+ const previous = outputTargets.get(target.platformArchABI)
273
+ if (previous) {
274
+ throw new Error(
275
+ `Targets ${previous} and ${targets[index]} produce the same ${target.platformArchABI} artifact set. Choose one target spelling.`,
276
+ )
277
+ }
278
+ outputTargets.set(target.platformArchABI, targets[index])
279
+ }
280
+
281
+ napiConfig.targets = parsedTargets
269
282
 
270
283
  return napiConfig
271
284
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
3
4
 
4
5
  import { debugFactory } from './log.js'
5
6
 
@@ -32,7 +33,7 @@ export interface Crate {
32
33
  manifest_path: string
33
34
  dependencies: Array<{
34
35
  name: string
35
- source: string
36
+ source: string | null
36
37
  req: string
37
38
  kind: string | null
38
39
  rename: string | null
@@ -44,11 +45,16 @@ export interface Crate {
44
45
  }>
45
46
  }
46
47
 
47
- export interface CrateResolveNode {
48
+ export interface CargoResolveNode {
48
49
  id: string
50
+ dependencies: string[]
49
51
  deps: Array<{
50
52
  name: string
51
53
  pkg: string
54
+ dep_kinds: Array<{
55
+ kind: string | null
56
+ target: string | null
57
+ }>
52
58
  }>
53
59
  features: string[]
54
60
  }
@@ -59,24 +65,18 @@ export interface CargoWorkspaceMetadata {
59
65
  workspace_members: string[]
60
66
  target_directory: string
61
67
  workspace_root: string
62
- resolve?: {
63
- nodes: CrateResolveNode[]
68
+ resolve: {
64
69
  root: string | null
70
+ nodes: CargoResolveNode[]
65
71
  } | null
66
72
  }
67
73
 
68
- export interface CargoFeatureOptions {
69
- features?: string[]
70
- allFeatures?: boolean
71
- noDefaultFeatures?: boolean
72
- }
73
-
74
74
  // Crates that will actually compile `napi-derive` in this build, and are therefore
75
75
  // expected to emit a type-def intermediate file. Prefer the resolved dependency graph:
76
- // a crate whose `napi-derive` dependency is optional and stays disabled never emits a
77
- // type-def file, so force-building it would invalidate cargo's fingerprint on every
78
- // build without ever satisfying the type-def check. Fall back to declared dependencies
79
- // when the resolve graph is unavailable.
76
+ // a crate whose `napi-derive` dependency is optional and stays disabled in the current
77
+ // build never emits a type-def file, so force-building it would invalidate cargo's
78
+ // fingerprint on every build without ever satisfying the type-def check. Fall back
79
+ // to declared dependencies when the resolve graph is unavailable.
80
80
  export function getNapiDeriveDependentCrates(
81
81
  metadata: CargoWorkspaceMetadata,
82
82
  ): Crate[] {
@@ -99,56 +99,164 @@ export function getNapiDeriveDependentCrates(
99
99
  return metadata.packages.filter((crate) => dependentPackageIds.has(crate.id))
100
100
  }
101
101
 
102
- export async function parseMetadata(
102
+ interface ParseMetadataOptions {
103
+ allFeatures?: boolean
104
+ cargoOptions?: string[]
105
+ cwd?: string
106
+ featurePackage?: string
107
+ features?: string[]
108
+ filterPlatform?: string
109
+ noDefaultFeatures?: boolean
110
+ }
111
+
112
+ export function createCargoMetadataInvocation(
103
113
  manifestPath: string,
104
- featureOptions?: CargoFeatureOptions,
114
+ options: ParseMetadataOptions = {},
105
115
  ) {
106
- if (!fs.existsSync(manifestPath)) {
107
- throw new Error(`No crate found in manifest: ${manifestPath}`)
116
+ const globalArgs: string[] = []
117
+ const metadataArgs = [
118
+ 'metadata',
119
+ '--manifest-path',
120
+ manifestPath,
121
+ '--format-version',
122
+ '1',
123
+ ]
124
+ const features = [
125
+ ...new Set(
126
+ (options.features ?? []).flatMap((feature) =>
127
+ feature.split(/[,\s]+/).filter(Boolean),
128
+ ),
129
+ ),
130
+ ]
131
+ let allFeatures = options.allFeatures === true
132
+ let noDefaultFeatures = options.noDefaultFeatures === true
133
+ const cargoOptions = options.cargoOptions ?? []
134
+ for (let index = 0; index < cargoOptions.length; index += 1) {
135
+ const option = cargoOptions[index]
136
+ if (option === '--config' || option === '-Z') {
137
+ const value = cargoOptions[index + 1]
138
+ if (value !== undefined) {
139
+ globalArgs.push(option, value)
140
+ index += 1
141
+ }
142
+ } else if (option.startsWith('--config=') || option.startsWith('-Z')) {
143
+ globalArgs.push(option)
144
+ } else if (option === '--features' || option === '-F') {
145
+ const value = cargoOptions[index + 1]
146
+ if (value !== undefined) {
147
+ features.push(...value.split(/[,\s]+/).filter(Boolean))
148
+ index += 1
149
+ }
150
+ } else if (option.startsWith('--features=') || option.startsWith('-F=')) {
151
+ features.push(
152
+ ...option
153
+ .slice(option.indexOf('=') + 1)
154
+ .split(/[,\s]+/)
155
+ .filter(Boolean),
156
+ )
157
+ } else if (option.startsWith('-F') && option.length > 2) {
158
+ features.push(
159
+ ...option
160
+ .slice(2)
161
+ .split(/[,\s]+/)
162
+ .filter(Boolean),
163
+ )
164
+ } else if (option === '--all-features') {
165
+ allFeatures = true
166
+ } else if (option === '--no-default-features') {
167
+ noDefaultFeatures = true
168
+ } else if (
169
+ option === '--locked' ||
170
+ option === '--offline' ||
171
+ option === '--frozen'
172
+ ) {
173
+ metadataArgs.push(option)
174
+ }
108
175
  }
109
-
110
- const featureArgs: string[] = []
111
- if (featureOptions?.allFeatures) {
112
- featureArgs.push('--all-features')
176
+ const selectedFeatures = [
177
+ ...new Set(
178
+ features.map((feature) =>
179
+ options.featurePackage && !feature.includes('/')
180
+ ? `${options.featurePackage}/${feature}`
181
+ : feature,
182
+ ),
183
+ ),
184
+ ].sort()
185
+ if (selectedFeatures.length > 0) {
186
+ metadataArgs.push('--features', selectedFeatures.join(','))
113
187
  }
114
- if (featureOptions?.noDefaultFeatures) {
115
- featureArgs.push('--no-default-features')
188
+ if (allFeatures) {
189
+ metadataArgs.push('--all-features')
116
190
  }
117
- if (featureOptions?.features?.length) {
118
- featureArgs.push('--features', featureOptions.features.join(','))
191
+ if (noDefaultFeatures) {
192
+ metadataArgs.push('--no-default-features')
193
+ }
194
+ if (options.filterPlatform) {
195
+ metadataArgs.push('--filter-platform', options.filterPlatform)
196
+ }
197
+
198
+ return {
199
+ // Deliberately NOT `process.env.CARGO ?? 'cargo'`: `$CARGO` only overrides
200
+ // the cargo/cross binary spawned for the actual build (see the Builder in
201
+ // api/build.ts) — tests poison `$CARGO` with a nonexistent path to assert
202
+ // no real build is spawned, and `cross metadata` is not a thing either.
203
+ // Metadata always comes from the host `cargo`.
204
+ command: 'cargo',
205
+ args: [...globalArgs, ...metadataArgs],
206
+ cwd: resolve(options.cwd ?? dirname(manifestPath)),
207
+ }
208
+ }
209
+
210
+ export async function parseMetadata(
211
+ manifestPath: string,
212
+ options: ParseMetadataOptions = {},
213
+ ) {
214
+ if (!fs.existsSync(manifestPath)) {
215
+ throw new Error(`No crate found in manifest: ${manifestPath}`)
119
216
  }
120
217
 
121
- // Resolving with the build's feature flags keeps `metadata.resolve` in sync with what
122
- // `cargo build` will actually compile. Feature flags are rejected in some setups
123
- // (e.g. a virtual workspace root manifest), so fall back to a plain invocation.
124
- if (featureArgs.length) {
218
+ const invocation = createCargoMetadataInvocation(manifestPath, options)
219
+ // Resolving with the build's feature flags keeps `metadata.resolve` in sync
220
+ // with what `cargo build` will actually compile. Feature flags are rejected
221
+ // in some setups (e.g. a virtual workspace root manifest), so fall back to a
222
+ // plain invocation without them.
223
+ const featurelessArgs = stripFeatureArgs(invocation.args)
224
+ if (featurelessArgs.length !== invocation.args.length) {
125
225
  try {
126
- return await execCargoMetadata(manifestPath, featureArgs)
226
+ return await execCargoMetadata(invocation)
127
227
  } catch (e) {
128
228
  debug.warn(
129
229
  `cargo metadata failed with feature flags, retrying without them: ${e}`,
130
230
  )
131
231
  }
232
+ return execCargoMetadata({ ...invocation, args: featurelessArgs })
132
233
  }
234
+ return execCargoMetadata(invocation)
235
+ }
133
236
 
134
- return execCargoMetadata(manifestPath, [])
237
+ function stripFeatureArgs(args: string[]): string[] {
238
+ const stripped: string[] = []
239
+ for (let index = 0; index < args.length; index += 1) {
240
+ const arg = args[index]
241
+ if (arg === '--features') {
242
+ // also skip the flag's value
243
+ index += 1
244
+ } else if (arg !== '--all-features' && arg !== '--no-default-features') {
245
+ stripped.push(arg)
246
+ }
247
+ }
248
+ return stripped
135
249
  }
136
250
 
137
- async function execCargoMetadata(manifestPath: string, extraArgs: string[]) {
138
- const childProcess = spawn(
139
- 'cargo',
140
- [
141
- 'metadata',
142
- '--manifest-path',
143
- manifestPath,
144
- '--format-version',
145
- '1',
146
- ...extraArgs,
147
- ],
148
- {
149
- stdio: 'pipe',
150
- },
151
- )
251
+ async function execCargoMetadata(invocation: {
252
+ command: string
253
+ args: string[]
254
+ cwd: string
255
+ }) {
256
+ const childProcess = spawn(invocation.command, invocation.args, {
257
+ cwd: invocation.cwd,
258
+ stdio: 'pipe',
259
+ })
152
260
 
153
261
  let stdout = ''
154
262
  let stderr = ''