@companion-module/tools 3.1.0 → 3.2.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.
@@ -1,74 +1,13 @@
1
- import { lstat, readdir, realpath, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
- import { createHash } from 'node:crypto';
1
+ import { readFile } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import * as esbuild from 'esbuild';
4
+ import { asLicenseString, collectInstalledPackages as collectInstalledPackagesWith, collectMetafilePackages as collectMetafilePackagesWith, createLegalInventory, } from '../../license/index.js';
5
5
  import { createEsbuildOptions, loadModuleBuildDefinition } from './bundle-util.js';
6
6
  import { resolveExternalDependencies, withInstalledExternalTree } from './external-install-util.js';
7
- import { knownPackageLicense } from './known-package-licenses.js';
8
- const SHORTHAND_REPOSITORY_HOSTS = {
9
- github: 'https://github.com/',
10
- gitlab: 'https://gitlab.com/',
11
- bitbucket: 'https://bitbucket.org/',
12
- };
13
- export function normalizeRepositoryUrl(packageJson) {
14
- const repository = packageJson.repository;
15
- const declared = typeof repository === 'string'
16
- ? repository
17
- : repository && typeof repository === 'object' && typeof repository.url === 'string'
18
- ? repository.url
19
- : undefined;
20
- let url = declared?.trim() ?? '';
21
- if (url) {
22
- // npm shorthands, either "user/repo" or "<host>:user/repo"
23
- if (/^[\w.-]+\/[\w.-]+$/.test(url))
24
- url = `${SHORTHAND_REPOSITORY_HOSTS.github}${url}`;
25
- const shorthand = /^([a-z]+):([\w.-]+\/[\w.-]+)$/.exec(url);
26
- if (shorthand && SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]) {
27
- url = `${SHORTHAND_REPOSITORY_HOSTS[shorthand[1]]}${shorthand[2]}`;
28
- }
29
- url = url.replace(/^git\+/, '').replace(/^(?:git|ssh):\/\/(?:git@)?/, 'https://');
30
- const scpLike = /^git@([^:]+):(.+)$/.exec(url);
31
- if (scpLike)
32
- url = `https://${scpLike[1]}/${scpLike[2]}`;
33
- url = url.replace(/\.git$/, '');
34
- }
35
- if (!/^https?:\/\//.test(url))
36
- url = typeof packageJson.homepage === 'string' ? packageJson.homepage.trim() : '';
37
- return /^https?:\/\//.test(url) ? url : undefined;
38
- }
39
- export function normalizeInventoryPath(inputPath, ownerRoot) {
40
- const relativePath = path.relative(ownerRoot, inputPath);
41
- if (relativePath === '' || relativePath === '.')
42
- return '.';
43
- if (relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
44
- return '<outside package root>';
45
- }
46
- return relativePath.split(path.sep).join('/');
47
- }
48
- async function readPackageJson(packageRoot) {
49
- return JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
50
- }
51
- function asLicenseString(license) {
52
- if (typeof license === 'string')
53
- return license;
54
- // npm deprecated { type, url } and arrays of it long ago, but older packages are still published with them
55
- if (Array.isArray(license)) {
56
- const choices = license.map(asLicenseString).filter((choice) => choice !== undefined);
57
- if (!choices.length)
58
- return undefined;
59
- return choices.length === 1 ? choices[0] : `(${choices.join(' OR ')})`; // An array offers a choice of licenses
60
- }
61
- if (license && typeof license === 'object')
62
- return asLicenseString(license.type);
63
- return undefined;
64
- }
65
- /** Reads a package's license, preferring the current field over the deprecated plural one */
66
- function declaredLicenseOf(packageJson) {
67
- return (asLicenseString(packageJson.license) ??
68
- asLicenseString(packageJson.licenses) ??
69
- // Only for packages declaring nothing at all, so this can never override what a package says about itself
70
- knownPackageLicense(packageJson.name, packageJson.version));
71
- }
7
+ import { MODULE_LICENSE_OVERRIDES } from './known-package-licenses.js';
8
+ // The engine itself is generic and lives in ../../license, so that it can also be used by the rest of the Companion
9
+ // project. These re-exports keep a single import for everything license related in the module scripts and tests.
10
+ export { NO_LICENSE_OVERRIDES, collectPackageLegalMaterial, collectPackagesFromInputPaths, createLegalInventory, getContributingInputs, normalizeInventoryPath, normalizeLegalText, normalizeRepositoryUrl, renderLicenseFile, renderNoticeFile, writeLegalArtifacts, } from '../../license/index.js';
72
11
  async function readManifestLicense(moduleDir) {
73
12
  try {
74
13
  const manifest = JSON.parse(await readFile(path.join(moduleDir, 'companion', 'manifest.json'), 'utf8'));
@@ -78,291 +17,26 @@ async function readManifestLicense(moduleDir) {
78
17
  return undefined; // Modules without a readable manifest fall back to the package.json license
79
18
  }
80
19
  }
81
- async function findPackageRoot(inputPath, moduleDir) {
82
- const nodeModulesDir = path.join(moduleDir, 'node_modules');
83
- if (!inputPath.startsWith(`${nodeModulesDir}${path.sep}`))
84
- return undefined;
85
- // A package owns everything below the directory node_modules resolves it to. Its own subdirectories can hold a
86
- // package.json marking their module type, sometimes with a name and version, so only this boundary identifies it.
87
- const segments = inputPath.split(path.sep);
88
- const rootIndex = segments.lastIndexOf('node_modules') + 1;
89
- const packageRoot = segments
90
- .slice(0, segments[rootIndex]?.startsWith('@') ? rootIndex + 2 : rootIndex + 1)
91
- .join(path.sep);
92
- try {
93
- await readPackageJson(packageRoot);
94
- return packageRoot;
95
- }
96
- catch {
97
- return undefined; // Without a package.json this is not a package, so the file counts as project code
98
- }
99
- }
100
- function getContributingInputs(metafile) {
101
- const inputs = new Set();
102
- for (const [outputPath, output] of Object.entries(metafile.outputs)) {
103
- if (!outputPath.endsWith('.js'))
104
- continue;
105
- for (const [inputPath, input] of Object.entries(output.inputs)) {
106
- if (input.bytesInOutput > 0)
107
- inputs.add(inputPath);
108
- }
109
- }
110
- return [...inputs];
111
- }
112
- function packageFromJson(kind, packageRoot, packageJson) {
20
+ /**
21
+ * How a module identifies itself to the engine. The manifest license is what the packaged module is distributed as,
22
+ * which is what its dependencies must fit. package.json only licenses the module's own source, so it is the fallback
23
+ * for modules not declaring the other.
24
+ */
25
+ export async function moduleProjectIdentity(moduleDir) {
113
26
  return {
114
- kind,
115
- name: packageJson.name ?? path.basename(packageRoot),
116
- version: packageJson.version,
117
- declaredLicense: declaredLicenseOf(packageJson),
118
- repositoryUrl: normalizeRepositoryUrl(packageJson),
119
- packageRoot,
120
- contributingPaths: new Set(),
27
+ projectRoots: [moduleDir],
28
+ packageRoot: moduleDir,
29
+ name: undefined,
30
+ distributionLicense: asLicenseString(await readManifestLicense(moduleDir)),
121
31
  };
122
32
  }
33
+ /** Scans a dependency tree installed alongside a module, applying the module tooling's own license overrides */
123
34
  export async function collectInstalledPackages(nodeModulesDir) {
124
- const packages = [];
125
- async function scan(currentNodeModulesDir) {
126
- let entries;
127
- try {
128
- entries = await readdir(currentNodeModulesDir, { withFileTypes: true });
129
- }
130
- catch {
131
- return;
132
- }
133
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
134
- if (entry.name === '.bin' || entry.isSymbolicLink())
135
- continue;
136
- if (entry.name.startsWith('@')) {
137
- if (!entry.isDirectory())
138
- continue;
139
- await scanScopedPackages(path.join(currentNodeModulesDir, entry.name));
140
- continue;
141
- }
142
- if (!entry.isDirectory())
143
- continue;
144
- await scanPackage(path.join(currentNodeModulesDir, entry.name));
145
- }
146
- }
147
- async function scanScopedPackages(scopeDir) {
148
- const entries = await readdir(scopeDir, { withFileTypes: true });
149
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
150
- if (entry.isDirectory() && !entry.isSymbolicLink())
151
- await scanPackage(path.join(scopeDir, entry.name));
152
- }
153
- }
154
- async function scanPackage(packageRoot) {
155
- try {
156
- packages.push(packageFromJson('external', packageRoot, await readPackageJson(packageRoot)));
157
- }
158
- catch {
159
- return;
160
- }
161
- await scan(path.join(packageRoot, 'node_modules'));
162
- }
163
- await scan(nodeModulesDir);
164
- return packages;
165
- }
166
- const MAX_LEGAL_FILE_SIZE = 1024 * 1024;
167
- export function normalizeLegalText(content) {
168
- return content.replace(/\r\n/g, '\n').trim();
169
- }
170
- function sha256(content) {
171
- return createHash('sha256').update(content).digest('hex');
172
- }
173
- function isPathInside(childPath, parentPath) {
174
- const relativePath = path.relative(parentPath, childPath);
175
- return (relativePath === '' ||
176
- (!relativePath.startsWith(`..${path.sep}`) && relativePath !== '..' && !path.isAbsolute(relativePath)));
177
- }
178
- function legalRole(filename) {
179
- if (/^(license|licence|copying)/i.test(filename))
180
- return 'license';
181
- if (/^notice/i.test(filename))
182
- return 'notice';
183
- return undefined;
35
+ return collectInstalledPackagesWith(nodeModulesDir, MODULE_LICENSE_OVERRIDES);
184
36
  }
185
- async function readLegalText(filePath, packageRoot, role) {
186
- const linkStat = await lstat(filePath);
187
- if (linkStat.isSymbolicLink())
188
- return undefined;
189
- const fileStat = await stat(filePath);
190
- if (!fileStat.isFile() || fileStat.size > MAX_LEGAL_FILE_SIZE)
191
- return undefined;
192
- const rawContent = await readFile(filePath, 'utf8');
193
- if (rawContent.includes('\0'))
194
- return undefined;
195
- const content = normalizeLegalText(rawContent);
196
- if (!content)
197
- return undefined;
198
- return { role, filename: normalizeInventoryPath(filePath, packageRoot), content, sha256: sha256(content) };
199
- }
200
- function sourceLoader(filename) {
201
- switch (path.extname(filename).toLowerCase()) {
202
- case '.js':
203
- case '.mjs':
204
- case '.cjs':
205
- return 'js';
206
- case '.ts':
207
- return 'ts';
208
- case '.tsx':
209
- return 'tsx';
210
- default:
211
- return undefined;
212
- }
213
- }
214
- export async function collectPackageLegalMaterial(packageInfo) {
215
- const diagnostics = [];
216
- const candidateFiles = new Map();
217
- for (const entry of await readdir(packageInfo.packageRoot, { withFileTypes: true })) {
218
- if (!entry.isFile())
219
- continue;
220
- const role = legalRole(entry.name);
221
- if (role)
222
- candidateFiles.set(entry.name, role);
223
- }
224
- const seeLicenseMatch = /^SEE LICENSE IN (.+)$/i.exec(packageInfo.declaredLicense ?? '');
225
- if (seeLicenseMatch) {
226
- const explicitPath = path.resolve(packageInfo.packageRoot, seeLicenseMatch[1]);
227
- if (!isPathInside(explicitPath, packageInfo.packageRoot)) {
228
- diagnostics.push(`Ignoring license file outside package root: ${seeLicenseMatch[1]}`);
229
- }
230
- else {
231
- candidateFiles.set(normalizeInventoryPath(explicitPath, packageInfo.packageRoot), 'license');
232
- }
233
- }
234
- const legalTexts = [];
235
- for (const [filename, role] of [...candidateFiles.entries()].sort(([a], [b]) => a.localeCompare(b))) {
236
- try {
237
- const legalText = await readLegalText(path.join(packageInfo.packageRoot, filename), packageInfo.packageRoot, role);
238
- if (legalText)
239
- legalTexts.push(legalText);
240
- else
241
- diagnostics.push(`Ignoring unreadable, binary, or oversized legal file: ${filename}`);
242
- }
243
- catch {
244
- diagnostics.push(`Ignoring unreadable legal file: ${filename}`);
245
- }
246
- }
247
- if (!legalTexts.some((text) => text.role === 'license')) {
248
- for (const sourcePath of [...packageInfo.contributingPaths].sort()) {
249
- const loader = sourceLoader(sourcePath);
250
- if (!loader) {
251
- diagnostics.push(`Ignoring unsupported source file for legal comments: ${sourcePath}`);
252
- continue;
253
- }
254
- try {
255
- const source = await readFile(path.join(packageInfo.packageRoot, sourcePath), 'utf8');
256
- const result = await esbuild.transform(source, { loader, legalComments: 'external' });
257
- if (result.legalComments) {
258
- const content = normalizeLegalText(result.legalComments);
259
- if (content) {
260
- legalTexts.push({
261
- role: 'source-comment',
262
- filename: sourcePath,
263
- content,
264
- sha256: sha256(content),
265
- });
266
- }
267
- }
268
- }
269
- catch {
270
- diagnostics.push(`Ignoring unreadable source file for legal comments: ${sourcePath}`);
271
- }
272
- }
273
- }
274
- const uniqueTexts = new Map();
275
- for (const legalText of legalTexts) {
276
- const key = legalText.role === 'source-comment'
277
- ? `${legalText.role}:${legalText.sha256}`
278
- : `${legalText.role}:${legalText.filename}:${legalText.sha256}`;
279
- uniqueTexts.set(key, legalText);
280
- }
281
- return { package: { ...packageInfo, legalTexts: [...uniqueTexts.values()] }, diagnostics };
282
- }
283
- function comparePackages(a, b) {
284
- if (a.kind === 'project' && b.kind !== 'project')
285
- return -1;
286
- if (a.kind !== 'project' && b.kind === 'project')
287
- return 1;
288
- return a.name.localeCompare(b.name) || (a.version ?? '').localeCompare(b.version ?? '');
289
- }
290
- function packageName(packageInfo) {
291
- return packageInfo.version ? `${packageInfo.name}@${packageInfo.version}` : packageInfo.name;
292
- }
293
- function renderLegalFile(inventory, roles) {
294
- const groupedTexts = new Map();
295
- for (const packageInfo of inventory.packages) {
296
- for (const text of packageInfo.legalTexts) {
297
- if (!roles.includes(text.role))
298
- continue;
299
- const group = groupedTexts.get(text.sha256) ?? { text, packages: new Map() };
300
- const packageKey = `${packageInfo.kind === 'project' ? 'project' : 'dependency'}:${packageName(packageInfo)}:${packageInfo.declaredLicense ?? ''}`;
301
- group.packages.set(packageKey, packageInfo);
302
- groupedTexts.set(text.sha256, group);
303
- }
304
- }
305
- if (!groupedTexts.size)
306
- return undefined;
307
- const sections = [...groupedTexts.values()].sort((a, b) => comparePackages([...a.packages.values()].sort(comparePackages)[0], [...b.packages.values()].sort(comparePackages)[0]));
308
- const separator = '-'.repeat(80);
309
- const renderedSections = sections.map((section) => {
310
- const packages = [...section.packages.values()].sort(comparePackages);
311
- const packageList = packages
312
- .map((packageInfo) => {
313
- const label = `${packageName(packageInfo)} — ${packageInfo.declaredLicense ?? 'UNKNOWN'}`;
314
- // Tells recipients where to obtain the source, which MPL-2.0 and the GPL family require
315
- return packageInfo.repositoryUrl ? `${label} (${packageInfo.repositoryUrl})` : label;
316
- })
317
- .join(', ');
318
- return `Packages: ${packageList}\n${separator}\n${normalizeLegalText(section.text.content)}`;
319
- });
320
- return `${renderedSections.join('\n\n')}\n`;
321
- }
322
- export function renderLicenseFile(inventory) {
323
- return renderLegalFile(inventory, ['license', 'source-comment']) ?? '';
324
- }
325
- export function renderNoticeFile(inventory) {
326
- return renderLegalFile(inventory, ['notice']);
327
- }
328
- export async function writeLegalArtifacts(outputDir, inventory) {
329
- await writeFile(path.join(outputDir, 'LICENSE'), renderLicenseFile(inventory));
330
- const notice = renderNoticeFile(inventory);
331
- const noticePath = path.join(outputDir, 'NOTICE');
332
- if (notice)
333
- await writeFile(noticePath, notice);
334
- else
335
- await rm(noticePath, { force: true });
336
- }
337
- const kindPriority = {
338
- project: 0,
339
- bundled: 1,
340
- external: 2,
341
- };
342
- export async function createLegalInventory(packages) {
343
- const mergedPackages = new Map();
344
- for (const packageInfo of packages) {
345
- const existing = mergedPackages.get(packageInfo.packageRoot);
346
- if (!existing) {
347
- mergedPackages.set(packageInfo.packageRoot, {
348
- ...packageInfo,
349
- contributingPaths: new Set(packageInfo.contributingPaths),
350
- });
351
- continue;
352
- }
353
- for (const sourcePath of packageInfo.contributingPaths)
354
- existing.contributingPaths.add(sourcePath);
355
- if (kindPriority[packageInfo.kind] < kindPriority[existing.kind])
356
- existing.kind = packageInfo.kind;
357
- }
358
- const diagnostics = [];
359
- const legalRecords = [];
360
- for (const packageInfo of mergedPackages.values()) {
361
- const material = await collectPackageLegalMaterial(packageInfo);
362
- legalRecords.push(material.package);
363
- diagnostics.push(...material.diagnostics);
364
- }
365
- return { packages: legalRecords, diagnostics };
37
+ /** Attributes the inputs of a module's esbuild metafile, applying the module tooling's own license overrides */
38
+ export async function collectMetafilePackages(moduleDir, metafile) {
39
+ return collectMetafilePackagesWith(metafile, moduleDir, await moduleProjectIdentity(moduleDir), MODULE_LICENSE_OVERRIDES);
366
40
  }
367
41
  export async function analyzeShippedLegalInventory(moduleDir) {
368
42
  const definition = await loadModuleBuildDefinition(moduleDir);
@@ -383,45 +57,3 @@ export async function analyzeShippedLegalInventory(moduleDir) {
383
57
  const inventory = await createLegalInventory(packages);
384
58
  return { ...inventory, diagnostics: [...metafilePackages.diagnostics, ...inventory.diagnostics] };
385
59
  }
386
- export async function collectMetafilePackages(moduleDir, metafile) {
387
- const resolvedModuleDir = await realpath(moduleDir);
388
- const projectPackageJson = await readPackageJson(resolvedModuleDir);
389
- const packages = new Map();
390
- const diagnostics = [];
391
- // The manifest license is what the packaged module is distributed as, which is what its dependencies must fit.
392
- // package.json only licenses the module's own source, so it is a fallback for modules not declaring the other.
393
- const projectLicense = (await readManifestLicense(resolvedModuleDir)) ?? declaredLicenseOf(projectPackageJson);
394
- for (const input of getContributingInputs(metafile)) {
395
- if (input.startsWith('<')) {
396
- diagnostics.push(`Ignoring virtual esbuild input: ${input}`);
397
- continue;
398
- }
399
- const inputPath = path.resolve(resolvedModuleDir, input);
400
- if (!isPathInside(inputPath, resolvedModuleDir)) {
401
- diagnostics.push(`Ignoring esbuild input outside module directory: ${input}`);
402
- continue;
403
- }
404
- // Resolve ownership using logical node_modules path. Realpath would turn linked
405
- // dependencies into paths outside moduleDir and incorrectly classify them as project code.
406
- const packageRoot = (await findPackageRoot(inputPath, resolvedModuleDir)) ?? resolvedModuleDir;
407
- const packageJson = packageRoot === resolvedModuleDir ? projectPackageJson : await readPackageJson(packageRoot);
408
- const kind = packageRoot === resolvedModuleDir ? 'project' : 'bundled';
409
- const key = `${kind}:${packageRoot}`;
410
- let packageInfo = packages.get(key);
411
- if (!packageInfo) {
412
- packageInfo = {
413
- kind,
414
- name: packageJson.name ?? path.basename(packageRoot),
415
- version: packageJson.version,
416
- declaredLicense: kind === 'project' ? asLicenseString(projectLicense) : declaredLicenseOf(packageJson),
417
- sourceLicense: kind === 'project' ? declaredLicenseOf(projectPackageJson) : undefined,
418
- repositoryUrl: normalizeRepositoryUrl(packageJson),
419
- packageRoot,
420
- contributingPaths: new Set(),
421
- };
422
- packages.set(key, packageInfo);
423
- }
424
- packageInfo.contributingPaths.add(normalizeInventoryPath(inputPath, packageRoot));
425
- }
426
- return { packages: [...packages.values()], diagnostics };
427
- }
@@ -1,27 +1,11 @@
1
- import type { LegalInventory, ShippedPackageLegalRecord } from './license-util.js';
1
+ import { type LegalInventory, type LicensePolicyDescriptor, type LicensePolicyIssue } from '../../license/index.js';
2
+ export { LicensePolicyError, SUPPORTED_PROJECT_LICENSES, resolveProjectLicense } from '../../license/index.js';
3
+ export type { LicensePolicyDescriptor, LicensePolicyIssue, ProjectLicense } from '../../license/index.js';
2
4
  export type ModuleType = 'connection' | 'surface';
3
- export interface LicensePolicyIssue {
4
- packageName: string;
5
- packageVersion?: string;
6
- packageKind: ShippedPackageLegalRecord['kind'];
7
- message: string;
8
- }
9
- type LicensePolicyOutput = Pick<NodeJS.WriteStream, 'write'>;
10
- export declare class LicensePolicyError extends Error {
11
- readonly issues: LicensePolicyIssue[];
12
- constructor(issues: LicensePolicyIssue[]);
13
- }
14
- /** Licenses a module may be declared as, each allowing a different set of dependency licenses */
15
- export type ProjectLicense = 'MIT' | 'GPL-2.0-only' | 'GPL-3.0-only';
16
- export declare const SUPPORTED_PROJECT_LICENSES: ProjectLicense[];
17
- /** Resolve the license the packaged module is distributed under, which selects the policy applied to its dependencies */
18
- export declare function resolveProjectLicense(inventory: LegalInventory): {
19
- declaration: string;
20
- license: ProjectLicense | undefined;
21
- };
5
+ /** How the policy engine talks about a module: what it is called, where it declares its licenses, and how it ships */
6
+ export declare const MODULE_LICENSE_POLICY: LicensePolicyDescriptor;
22
7
  export declare function createLicensePolicyIssues(inventory: LegalInventory): LicensePolicyIssue[];
23
8
  export declare function enforceLicensePolicy(inventory: LegalInventory, options?: {
24
9
  ignoreLicenseRules?: boolean;
25
- stderr?: LicensePolicyOutput;
10
+ stderr?: Pick<NodeJS.WriteStream, 'write'>;
26
11
  }): void;
27
- export {};