@companion-module/tools 2.7.2 → 2.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.
@@ -0,0 +1,681 @@
1
+ import { lstat, readdir, realpath, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { createRequire } from 'node:module'
4
+ import path from 'node:path'
5
+ import { knownPackageLicense } from './known-package-licenses.js'
6
+
7
+ /**
8
+ * @typedef {'project' | 'bundled' | 'external'} ShippedPackageKind
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} ShippedPackage
13
+ * @property {ShippedPackageKind} kind
14
+ * @property {string} name
15
+ * @property {string | undefined} [version]
16
+ * @property {string | undefined} [declaredLicense]
17
+ * @property {string | undefined} [sourceLicense] Only for the project, the package.json license of its own source, which declaredLicense distributes it under
18
+ * @property {string | undefined} [repositoryUrl] Where recipients can obtain the source of this package, as required by MPL-2.0 and the GPL family
19
+ * @property {string} packageRoot
20
+ * @property {Set<string>} contributingPaths
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} ShippedPackageCollection
25
+ * @property {ShippedPackage[]} packages
26
+ * @property {string[]} diagnostics
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} LegalText
31
+ * @property {'license' | 'notice' | 'source-comment'} role
32
+ * @property {string} filename
33
+ * @property {string} content
34
+ * @property {string} sha256
35
+ */
36
+
37
+ /**
38
+ * @typedef {ShippedPackage & { legalTexts: LegalText[] }} ShippedPackageLegalRecord
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} PackageLegalMaterial
43
+ * @property {ShippedPackageLegalRecord} package
44
+ * @property {string[]} diagnostics
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} LegalInventory
49
+ * @property {ShippedPackageLegalRecord[]} packages
50
+ * @property {string[]} diagnostics
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} PackageJson
55
+ * @property {string} [name]
56
+ * @property {string} [version]
57
+ * @property {unknown} [license]
58
+ * @property {unknown} [licenses]
59
+ * @property {unknown} [repository]
60
+ * @property {unknown} [homepage]
61
+ * @property {unknown} [dependencies]
62
+ * @property {unknown} [optionalDependencies]
63
+ * @property {unknown} [peerDependencies]
64
+ */
65
+
66
+ /** @type {Record<string, string>} */
67
+ const SHORTHAND_REPOSITORY_HOSTS = {
68
+ github: 'https://github.com/',
69
+ gitlab: 'https://gitlab.com/',
70
+ bitbucket: 'https://bitbucket.org/',
71
+ }
72
+
73
+ /**
74
+ * @param {PackageJson} packageJson
75
+ * @returns {string | undefined}
76
+ */
77
+ export function normalizeRepositoryUrl(packageJson) {
78
+ const repository = packageJson.repository
79
+ const declared =
80
+ typeof repository === 'string'
81
+ ? repository
82
+ : repository && typeof repository === 'object' && typeof repository.url === 'string'
83
+ ? repository.url
84
+ : undefined
85
+
86
+ let url = declared?.trim() ?? ''
87
+ if (url) {
88
+ // npm shorthands, either "user/repo" or "<host>:user/repo"
89
+ if (/^[\w.-]+\/[\w.-]+$/.test(url)) url = `${SHORTHAND_REPOSITORY_HOSTS.github}${url}`
90
+ const shorthand = /^([a-z]+):([\w.-]+\/[\w.-]+)$/.exec(url)
91
+ if (shorthand && SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]) {
92
+ url = `${SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]}${shorthand[2]}`
93
+ }
94
+
95
+ url = url.replace(/^git\+/, '').replace(/^(?:git|ssh):\/\/(?:git@)?/, 'https://')
96
+ const scpLike = /^git@([^:]+):(.+)$/.exec(url)
97
+ if (scpLike) url = `https://${scpLike[1]}/${scpLike[2]}`
98
+ url = url.replace(/\.git$/, '')
99
+ }
100
+
101
+ if (!/^https?:\/\//.test(url)) url = typeof packageJson.homepage === 'string' ? packageJson.homepage.trim() : ''
102
+ return /^https?:\/\//.test(url) ? url : undefined
103
+ }
104
+
105
+ /**
106
+ * @param {string} inputPath
107
+ * @param {string} ownerRoot
108
+ * @returns {string}
109
+ */
110
+ export function normalizeInventoryPath(inputPath, ownerRoot) {
111
+ const relativePath = path.relative(ownerRoot, inputPath)
112
+ if (relativePath === '' || relativePath === '.') return '.'
113
+ if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
114
+ return '<outside package root>'
115
+ }
116
+
117
+ return relativePath.split(path.sep).join('/')
118
+ }
119
+
120
+ /**
121
+ * @param {string} packageRoot
122
+ * @returns {Promise<PackageJson>}
123
+ */
124
+ async function readPackageJson(packageRoot) {
125
+ return JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'))
126
+ }
127
+
128
+ /**
129
+ * @param {unknown} license
130
+ * @returns {string | undefined}
131
+ */
132
+ function asLicenseString(license) {
133
+ if (typeof license === 'string') return license
134
+ // npm deprecated { type, url } and arrays of it long ago, but older packages are still published with them
135
+ if (Array.isArray(license)) {
136
+ const choices = license.map(asLicenseString).filter((choice) => choice !== undefined)
137
+ if (!choices.length) return undefined
138
+ return choices.length === 1 ? choices[0] : `(${choices.join(' OR ')})` // An array offers a choice of licenses
139
+ }
140
+ if (license && typeof license === 'object') return asLicenseString(license.type)
141
+ return undefined
142
+ }
143
+
144
+ /**
145
+ * Reads a package's license, preferring the current field over the deprecated plural one
146
+ * @param {PackageJson} packageJson
147
+ * @returns {string | undefined}
148
+ */
149
+ function declaredLicenseOf(packageJson) {
150
+ return (
151
+ asLicenseString(packageJson.license) ??
152
+ asLicenseString(packageJson.licenses) ??
153
+ // Only for packages declaring nothing at all, so this can never override what a package says about itself
154
+ knownPackageLicense(packageJson.name, packageJson.version)
155
+ )
156
+ }
157
+
158
+ /**
159
+ * @param {string} moduleDir
160
+ * @returns {Promise<unknown>}
161
+ */
162
+ async function readManifestLicense(moduleDir) {
163
+ try {
164
+ const manifest = JSON.parse(await readFile(path.join(moduleDir, 'companion', 'manifest.json'), 'utf8'))
165
+ return manifest.license
166
+ } catch {
167
+ return undefined // Modules without a readable manifest fall back to the package.json license
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Resolve where node would load `name` from when it is required by code in `fromDir`, by walking up the node_modules
173
+ * chain. require.resolve is not usable here, it fails for packages which declare no main and for packages whose
174
+ * exports map does not expose a bare entry. The logical path is returned rather than the realpath, so that linked
175
+ * dependencies keep the identity node_modules gave them.
176
+ *
177
+ * @param {string} fromDir
178
+ * @param {string} name
179
+ * @returns {Promise<string | undefined>}
180
+ */
181
+ async function resolvePackageDir(fromDir, name) {
182
+ let currentDir = fromDir
183
+ for (;;) {
184
+ // A node_modules directory never contains a nested node_modules of its own
185
+ if (path.basename(currentDir) !== 'node_modules') {
186
+ const candidate = path.join(currentDir, 'node_modules', name)
187
+ try {
188
+ await readPackageJson(candidate)
189
+ return candidate
190
+ } catch {
191
+ // Not installed at this level, keep walking up
192
+ }
193
+ }
194
+
195
+ const parentDir = path.dirname(currentDir)
196
+ if (parentDir === currentDir) return undefined
197
+ currentDir = parentDir
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The dependency names a package pulls in at runtime. devDependencies are deliberately absent, npm and yarn never
203
+ * install them transitively and nothing bundles them, so they are not part of what ships.
204
+ *
205
+ * @param {PackageJson} packageJson
206
+ * @returns {{ name: string, optional: boolean }[]}
207
+ */
208
+ function dependencyNames(packageJson) {
209
+ /** @type {{ name: string, optional: boolean }[]} */
210
+ const names = []
211
+
212
+ /**
213
+ * @param {unknown} value
214
+ * @param {boolean} optional
215
+ */
216
+ const record = (value, optional) => {
217
+ if (!value || typeof value !== 'object') return
218
+ for (const name of Object.keys(value)) names.push({ name, optional })
219
+ }
220
+
221
+ record(packageJson.dependencies, false)
222
+ // Optional and peer dependencies only ship when they actually resolved on disk, so a missing one is not a problem
223
+ record(packageJson.optionalDependencies, true)
224
+ record(packageJson.peerDependencies, true)
225
+ return names
226
+ }
227
+
228
+ /**
229
+ * @param {ShippedPackageKind} kind
230
+ * @param {string} packageRoot
231
+ * @param {PackageJson} packageJson
232
+ * @returns {ShippedPackage}
233
+ */
234
+ function packageFromJson(kind, packageRoot, packageJson) {
235
+ return {
236
+ kind,
237
+ name: packageJson.name ?? path.basename(packageRoot),
238
+ version: packageJson.version,
239
+ declaredLicense: declaredLicenseOf(packageJson),
240
+ repositoryUrl: normalizeRepositoryUrl(packageJson),
241
+ packageRoot,
242
+ contributingPaths: new Set(),
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {string} nodeModulesDir
248
+ * @returns {Promise<ShippedPackage[]>}
249
+ */
250
+ export async function collectInstalledPackages(nodeModulesDir) {
251
+ /** @type {ShippedPackage[]} */
252
+ const packages = []
253
+
254
+ /**
255
+ * @param {string} currentNodeModulesDir
256
+ * @returns {Promise<void>}
257
+ */
258
+ async function scan(currentNodeModulesDir) {
259
+ let entries
260
+ try {
261
+ entries = await readdir(currentNodeModulesDir, { withFileTypes: true })
262
+ } catch {
263
+ return
264
+ }
265
+
266
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
267
+ if (entry.name === '.bin' || entry.isSymbolicLink()) continue
268
+ if (entry.name.startsWith('@')) {
269
+ if (!entry.isDirectory()) continue
270
+ await scanScopedPackages(path.join(currentNodeModulesDir, entry.name))
271
+ continue
272
+ }
273
+ if (!entry.isDirectory()) continue
274
+ await scanPackage(path.join(currentNodeModulesDir, entry.name))
275
+ }
276
+ }
277
+
278
+ /**
279
+ * @param {string} scopeDir
280
+ * @returns {Promise<void>}
281
+ */
282
+ async function scanScopedPackages(scopeDir) {
283
+ const entries = await readdir(scopeDir, { withFileTypes: true })
284
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
285
+ if (entry.isDirectory() && !entry.isSymbolicLink()) await scanPackage(path.join(scopeDir, entry.name))
286
+ }
287
+ }
288
+
289
+ /**
290
+ * @param {string} packageRoot
291
+ * @returns {Promise<void>}
292
+ */
293
+ async function scanPackage(packageRoot) {
294
+ try {
295
+ packages.push(packageFromJson('external', packageRoot, await readPackageJson(packageRoot)))
296
+ } catch {
297
+ return
298
+ }
299
+ await scan(path.join(packageRoot, 'node_modules'))
300
+ }
301
+
302
+ await scan(nodeModulesDir)
303
+ return packages
304
+ }
305
+
306
+ const MAX_LEGAL_FILE_SIZE = 1024 * 1024
307
+
308
+ /**
309
+ * @param {string} content
310
+ * @returns {string}
311
+ */
312
+ export function normalizeLegalText(content) {
313
+ return content.replace(/\r\n/g, '\n').trim()
314
+ }
315
+
316
+ /**
317
+ * @param {string} content
318
+ * @returns {string}
319
+ */
320
+ function sha256(content) {
321
+ return createHash('sha256').update(content).digest('hex')
322
+ }
323
+
324
+ /**
325
+ * @param {string} childPath
326
+ * @param {string} parentPath
327
+ * @returns {boolean}
328
+ */
329
+ function isPathInside(childPath, parentPath) {
330
+ const relativePath = path.relative(parentPath, childPath)
331
+ return (
332
+ relativePath === '' ||
333
+ (!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath))
334
+ )
335
+ }
336
+
337
+ /**
338
+ * @param {string} filename
339
+ * @returns {LegalText['role'] | undefined}
340
+ */
341
+ function legalRole(filename) {
342
+ if (/^(license|licence|copying)/i.test(filename)) return 'license'
343
+ if (/^notice/i.test(filename)) return 'notice'
344
+ return undefined
345
+ }
346
+
347
+ /**
348
+ * @param {string} filePath
349
+ * @param {string} packageRoot
350
+ * @param {LegalText['role']} role
351
+ * @returns {Promise<LegalText | undefined>}
352
+ */
353
+ async function readLegalText(filePath, packageRoot, role) {
354
+ const linkStat = await lstat(filePath)
355
+ if (linkStat.isSymbolicLink()) return undefined
356
+ const fileStat = await stat(filePath)
357
+ if (!fileStat.isFile() || fileStat.size > MAX_LEGAL_FILE_SIZE) return undefined
358
+ const rawContent = await readFile(filePath, 'utf8')
359
+ if (rawContent.includes('\0')) return undefined
360
+ const content = normalizeLegalText(rawContent)
361
+ if (!content) return undefined
362
+ return { role, filename: normalizeInventoryPath(filePath, packageRoot), content, sha256: sha256(content) }
363
+ }
364
+
365
+ /**
366
+ * @param {ShippedPackage} packageInfo
367
+ * @returns {Promise<PackageLegalMaterial>}
368
+ */
369
+ export async function collectPackageLegalMaterial(packageInfo) {
370
+ /** @type {string[]} */
371
+ const diagnostics = []
372
+ /** @type {Map<string, LegalText['role']>} */
373
+ const candidateFiles = new Map()
374
+ for (const entry of await readdir(packageInfo.packageRoot, { withFileTypes: true })) {
375
+ if (!entry.isFile()) continue
376
+ const role = legalRole(entry.name)
377
+ if (role) candidateFiles.set(entry.name, role)
378
+ }
379
+
380
+ const seeLicenseMatch = /^SEE LICENSE IN (.+)$/i.exec(packageInfo.declaredLicense ?? '')
381
+ if (seeLicenseMatch) {
382
+ const explicitPath = path.resolve(packageInfo.packageRoot, seeLicenseMatch[1])
383
+ if (!isPathInside(explicitPath, packageInfo.packageRoot)) {
384
+ diagnostics.push(`Ignoring license file outside package root: ${seeLicenseMatch[1]}`)
385
+ } else {
386
+ candidateFiles.set(normalizeInventoryPath(explicitPath, packageInfo.packageRoot), 'license')
387
+ }
388
+ }
389
+
390
+ /** @type {LegalText[]} */
391
+ const legalTexts = []
392
+ for (const [filename, role] of [...candidateFiles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
393
+ try {
394
+ const legalText = await readLegalText(path.join(packageInfo.packageRoot, filename), packageInfo.packageRoot, role)
395
+ if (legalText) legalTexts.push(legalText)
396
+ else diagnostics.push(`Ignoring unreadable, binary, or oversized legal file: ${filename}`)
397
+ } catch {
398
+ diagnostics.push(`Ignoring unreadable legal file: ${filename}`)
399
+ }
400
+ }
401
+
402
+ // Where a package ships no license file at all, the esbuild build scrapes the `/*! ... */` banners out of the
403
+ // sources it bundled. Neither esbuild nor the set of bundled files is available here, so report it instead.
404
+ if (!legalTexts.some((text) => text.role === 'license')) {
405
+ diagnostics.push(
406
+ `No license file found in package: ${packageInfo.name}${packageInfo.version ? `@${packageInfo.version}` : ''}`,
407
+ )
408
+ }
409
+
410
+ /** @type {Map<string, LegalText>} */
411
+ const uniqueTexts = new Map()
412
+ for (const legalText of legalTexts) {
413
+ const key =
414
+ legalText.role === 'source-comment'
415
+ ? `${legalText.role}:${legalText.sha256}`
416
+ : `${legalText.role}:${legalText.filename}:${legalText.sha256}`
417
+ uniqueTexts.set(key, legalText)
418
+ }
419
+ return { package: { ...packageInfo, legalTexts: [...uniqueTexts.values()] }, diagnostics }
420
+ }
421
+
422
+ /**
423
+ * @param {ShippedPackageLegalRecord} a
424
+ * @param {ShippedPackageLegalRecord} b
425
+ * @returns {number}
426
+ */
427
+ function comparePackages(a, b) {
428
+ if (a.kind === 'project' && b.kind !== 'project') return -1
429
+ if (a.kind !== 'project' && b.kind === 'project') return 1
430
+ return a.name.localeCompare(b.name) || (a.version ?? '').localeCompare(b.version ?? '')
431
+ }
432
+
433
+ /**
434
+ * @param {ShippedPackageLegalRecord} packageInfo
435
+ * @returns {string}
436
+ */
437
+ function packageName(packageInfo) {
438
+ return packageInfo.version ? `${packageInfo.name}@${packageInfo.version}` : packageInfo.name
439
+ }
440
+
441
+ /**
442
+ * @param {LegalInventory} inventory
443
+ * @param {LegalText['role'][]} roles
444
+ * @returns {string | undefined}
445
+ */
446
+ function renderLegalFile(inventory, roles) {
447
+ /** @type {Map<string, { text: LegalText, packages: Map<string, ShippedPackageLegalRecord> }>} */
448
+ const groupedTexts = new Map()
449
+ for (const packageInfo of inventory.packages) {
450
+ for (const text of packageInfo.legalTexts) {
451
+ if (!roles.includes(text.role)) continue
452
+ const group = groupedTexts.get(text.sha256) ?? { text, packages: new Map() }
453
+ const packageKey = `${packageInfo.kind === 'project' ? 'project' : 'dependency'}:${packageName(packageInfo)}:${packageInfo.declaredLicense ?? ''}`
454
+ group.packages.set(packageKey, packageInfo)
455
+ groupedTexts.set(text.sha256, group)
456
+ }
457
+ }
458
+ if (!groupedTexts.size) return undefined
459
+
460
+ const sections = [...groupedTexts.values()].sort((a, b) =>
461
+ comparePackages(
462
+ [...a.packages.values()].sort(comparePackages)[0],
463
+ [...b.packages.values()].sort(comparePackages)[0],
464
+ ),
465
+ )
466
+ const separator = '-'.repeat(80)
467
+ const renderedSections = sections.map((section) => {
468
+ const packages = [...section.packages.values()].sort(comparePackages)
469
+ const packageList = packages
470
+ .map((packageInfo) => {
471
+ const label = `${packageName(packageInfo)} — ${packageInfo.declaredLicense ?? 'UNKNOWN'}`
472
+ // Tells recipients where to obtain the source, which MPL-2.0 and the GPL family require
473
+ return packageInfo.repositoryUrl ? `${label} (${packageInfo.repositoryUrl})` : label
474
+ })
475
+ .join(', ')
476
+ return `Packages: ${packageList}\n${separator}\n${normalizeLegalText(section.text.content)}`
477
+ })
478
+ return `${renderedSections.join('\n\n')}\n`
479
+ }
480
+
481
+ /**
482
+ * @param {LegalInventory} inventory
483
+ * @returns {string}
484
+ */
485
+ export function renderLicenseFile(inventory) {
486
+ return renderLegalFile(inventory, ['license', 'source-comment']) ?? ''
487
+ }
488
+
489
+ /**
490
+ * @param {LegalInventory} inventory
491
+ * @returns {string | undefined}
492
+ */
493
+ export function renderNoticeFile(inventory) {
494
+ return renderLegalFile(inventory, ['notice'])
495
+ }
496
+
497
+ /**
498
+ * @param {string} outputDir
499
+ * @param {LegalInventory} inventory
500
+ * @returns {Promise<void>}
501
+ */
502
+ export async function writeLegalArtifacts(outputDir, inventory) {
503
+ await writeFile(path.join(outputDir, 'LICENSE'), renderLicenseFile(inventory))
504
+ const notice = renderNoticeFile(inventory)
505
+ const noticePath = path.join(outputDir, 'NOTICE')
506
+ if (notice) await writeFile(noticePath, notice)
507
+ else await rm(noticePath, { force: true })
508
+ }
509
+
510
+ /** @type {Record<ShippedPackageKind, number>} */
511
+ const kindPriority = {
512
+ project: 0,
513
+ bundled: 1,
514
+ external: 2,
515
+ }
516
+
517
+ /**
518
+ * @param {ShippedPackage[]} packages
519
+ * @returns {Promise<LegalInventory>}
520
+ */
521
+ export async function createLegalInventory(packages) {
522
+ /** @type {Map<string, ShippedPackage>} */
523
+ const mergedPackages = new Map()
524
+ for (const packageInfo of packages) {
525
+ const existing = mergedPackages.get(packageInfo.packageRoot)
526
+ if (!existing) {
527
+ mergedPackages.set(packageInfo.packageRoot, {
528
+ ...packageInfo,
529
+ contributingPaths: new Set(packageInfo.contributingPaths),
530
+ })
531
+ continue
532
+ }
533
+ for (const sourcePath of packageInfo.contributingPaths) existing.contributingPaths.add(sourcePath)
534
+ if (kindPriority[packageInfo.kind] < kindPriority[existing.kind]) existing.kind = packageInfo.kind
535
+ }
536
+
537
+ /** @type {string[]} */
538
+ const diagnostics = []
539
+ /** @type {ShippedPackageLegalRecord[]} */
540
+ const legalRecords = []
541
+ for (const packageInfo of mergedPackages.values()) {
542
+ const material = await collectPackageLegalMaterial(packageInfo)
543
+ legalRecords.push(material.package)
544
+ diagnostics.push(...material.diagnostics)
545
+ }
546
+ return { packages: legalRecords, diagnostics }
547
+ }
548
+
549
+ /**
550
+ * The names a module's build-config.cjs declares as externals. Only the object form is read, matching what the build
551
+ * itself installs into the package as dependencies.
552
+ *
553
+ * @param {string} moduleDir
554
+ * @returns {string[]}
555
+ */
556
+ export function readExternalNames(moduleDir) {
557
+ let buildConfig
558
+ try {
559
+ buildConfig = createRequire(path.join(moduleDir, 'package.json'))(path.join(moduleDir, 'build-config.cjs'))
560
+ } catch {
561
+ return [] // Modules without a build-config.cjs bundle everything
562
+ }
563
+
564
+ const externals = Array.isArray(buildConfig.externals)
565
+ ? buildConfig.externals
566
+ : buildConfig.externals
567
+ ? [buildConfig.externals]
568
+ : []
569
+ const names = []
570
+ for (const group of externals) {
571
+ if (group && typeof group === 'object') names.push(...Object.keys(group))
572
+ }
573
+ return names
574
+ }
575
+
576
+ /**
577
+ * @param {string} moduleDir
578
+ * @returns {Promise<LegalInventory>}
579
+ */
580
+ export async function analyzeShippedLegalInventory(moduleDir) {
581
+ const shippedPackages = await collectProductionPackages(moduleDir, readExternalNames(moduleDir))
582
+ const inventory = await createLegalInventory(shippedPackages.packages)
583
+ return { ...inventory, diagnostics: [...shippedPackages.diagnostics, ...inventory.diagnostics] }
584
+ }
585
+
586
+ /**
587
+ * The set of packages which ship inside a built module. The esbuild build reads this out of the bundler metafile, so
588
+ * it sees exactly the files which made it into the bundle. webpack exposes no equivalent to a detached `npx webpack`,
589
+ * so the production dependency tree is walked instead. That is a superset of what actually gets bundled, anything
590
+ * tree-shaken away is still reported.
591
+ *
592
+ * @param {string} moduleDir
593
+ * @param {string[]} [externalNames] Names the build treats as externals, which ship beside the bundle rather than in it
594
+ * @returns {Promise<ShippedPackageCollection>}
595
+ */
596
+ export async function collectProductionPackages(moduleDir, externalNames = []) {
597
+ const resolvedModuleDir = await realpath(moduleDir)
598
+ const projectPackageJson = await readPackageJson(resolvedModuleDir)
599
+ /** @type {ShippedPackage[]} */
600
+ const packages = []
601
+ /** @type {string[]} */
602
+ const diagnostics = []
603
+
604
+ // The manifest license is what the packaged module is distributed as, which is what its dependencies must fit.
605
+ // package.json only licenses the module's own source, so it is a fallback for modules not declaring the other.
606
+ const projectLicense = (await readManifestLicense(resolvedModuleDir)) ?? declaredLicenseOf(projectPackageJson)
607
+
608
+ packages.push({
609
+ kind: 'project',
610
+ name: projectPackageJson.name ?? path.basename(resolvedModuleDir),
611
+ version: projectPackageJson.version,
612
+ declaredLicense: asLicenseString(projectLicense),
613
+ sourceLicense: declaredLicenseOf(projectPackageJson),
614
+ repositoryUrl: normalizeRepositoryUrl(projectPackageJson),
615
+ packageRoot: resolvedModuleDir,
616
+ contributingPaths: new Set(),
617
+ })
618
+
619
+ const externalNameSet = new Set(externalNames)
620
+ const visited = new Set([resolvedModuleDir])
621
+ /** @type {{ dir: string, packageJson: PackageJson }[]} */
622
+ const deferredExternals = []
623
+
624
+ /**
625
+ * @param {ShippedPackageKind} kind
626
+ * @param {{ dir: string, packageJson: PackageJson }[]} roots
627
+ * @param {boolean} stopAtExternals
628
+ * @returns {Promise<void>}
629
+ */
630
+ async function walk(kind, roots, stopAtExternals) {
631
+ let queue = roots
632
+ while (queue.length) {
633
+ /** @type {{ dir: string, packageJson: PackageJson }[]} */
634
+ const nextQueue = []
635
+ for (const entry of queue) {
636
+ for (const dependency of dependencyNames(entry.packageJson)) {
637
+ const packageRoot = await resolvePackageDir(entry.dir, dependency.name)
638
+ if (!packageRoot) {
639
+ if (!dependency.optional) {
640
+ diagnostics.push(
641
+ `Ignoring dependency which is not installed: ${dependency.name} (required by ${entry.packageJson.name ?? entry.dir})`,
642
+ )
643
+ }
644
+ continue
645
+ }
646
+ if (visited.has(packageRoot)) continue
647
+
648
+ const packageJson = await readPackageJson(packageRoot)
649
+ // The bundler externalises a name wherever it is required, so an external and everything below it
650
+ // is installed beside the bundle rather than built into it. Deferred rather than taken now, as a
651
+ // package an external depends on may still be reachable by a path which does bundle it.
652
+ if (stopAtExternals && externalNameSet.has(dependency.name)) {
653
+ deferredExternals.push({ dir: packageRoot, packageJson })
654
+ continue
655
+ }
656
+
657
+ visited.add(packageRoot)
658
+ packages.push(packageFromJson(kind, packageRoot, packageJson))
659
+ nextQueue.push({ dir: packageRoot, packageJson })
660
+ }
661
+ }
662
+ queue = nextQueue
663
+ }
664
+ }
665
+
666
+ // What the bundler builds into main.js, stopping wherever an external takes over
667
+ await walk('bundled', [{ dir: resolvedModuleDir, packageJson: projectPackageJson }], true)
668
+
669
+ /** @type {{ dir: string, packageJson: PackageJson }[]} */
670
+ const externalRoots = []
671
+ for (const root of deferredExternals) {
672
+ if (visited.has(root.dir)) continue // Already reached by a path which bundles it, which is the stricter answer
673
+ visited.add(root.dir)
674
+ packages.push(packageFromJson('external', root.dir, root.packageJson))
675
+ externalRoots.push(root)
676
+ }
677
+ // The externals and their own dependencies, which are installed alongside the module and loaded at runtime
678
+ await walk('external', externalRoots, false)
679
+
680
+ return { packages, diagnostics }
681
+ }