@companion-module/tools 3.1.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.2.0](https://github.com/bitfocus/companion-module-tools/compare/v3.1.1...v3.2.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * verify native prebuilds cover all supported platforms during build ([#119](https://github.com/bitfocus/companion-module-tools/issues/119)) ([86bce12](https://github.com/bitfocus/companion-module-tools/commit/86bce12b4172092f482241fd3cc1a58e9f39480c))
9
+
3
10
  ## [3.1.1](https://github.com/bitfocus/companion-module-tools/compare/v3.1.0...v3.1.1) (2026-10-01)
4
11
 
5
12
 
package/README.md CHANGED
@@ -37,6 +37,18 @@ what they publish, so open a PR there if you hit one. A license a package declar
37
37
 
38
38
  Pass `--ignore-license-rules` to report license problems as warnings instead of failing the command.
39
39
 
40
+ ## Native prebuilds
41
+
42
+ When building, every native (`.node`) binary in the package is checked to make sure each native library has binaries
43
+ for all the platforms Companion supports: `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`, `darwin-x64` and
44
+ `darwin-arm64`. A universal macOS binary counts for both macOS architectures.
45
+
46
+ The platform of each binary is read from the file itself, so any layout is supported (`pkg-prebuilds`, `prebuildify`
47
+ and per-platform packages). Libraries which only ship a binary for the build machine (eg via `prebuild-install` or
48
+ per-platform optional dependencies) will fail this check, as the module would not work on other platforms.
49
+
50
+ Pass `--ignore-prebuild-rules` to report missing platforms as warnings instead of failing the build.
51
+
40
52
  ## Upgrading from v1.x to v2.0
41
53
 
42
54
  v2.0 of this library includes some breaking changes to how eslint and prettier handled. Instead of it being installed as a dependency and being directly usable by modules, it has to be explicitly installed by modules.
@@ -3,17 +3,19 @@
3
3
  import 'zx/globals';
4
4
  import { buildPackage } from './lib/build-util.js';
5
5
  import { LicensePolicyError } from './lib/license-warning-util.js';
6
- import { ignoreLicenseRules } from './lib/cli-options.js';
6
+ import { PrebuildCoverageError } from './lib/prebuild-check-util.js';
7
+ import { ignoreLicenseRules, ignorePrebuildRules } from './lib/cli-options.js';
7
8
  if (process.platform === 'win32') {
8
9
  usePowerShell(); // to enable powershell
9
10
  }
10
11
  if (argv.help) {
11
- console.log('Usage: companion-module-build [--dev] [--prerelease] [--ignore-license-rules]');
12
+ console.log('Usage: companion-module-build [--dev] [--prerelease] [--ignore-license-rules] [--ignore-prebuild-rules]');
12
13
  console.log('Builds the companion connection module');
13
14
  console.log(' --dev: Build in development mode. This will not minify the code, making it easier to debug.');
14
15
  console.log(' --prerelease: Build in prerelease mode. This gets added as metadata to the manifest');
15
16
  console.log(' --output <filename>: Output to a specific filename, without a file extension');
16
17
  console.log(' --ignore-license-rules: Report license policy issues as warnings instead of failing');
18
+ console.log(' --ignore-prebuild-rules: Report native libraries missing binaries for a required platform as warnings instead of failing');
17
19
  process.exit(0);
18
20
  }
19
21
  const { validateManifest } = await import('@companion-module/base/manifest').catch((e) => {
@@ -22,10 +24,11 @@ const { validateManifest } = await import('@companion-module/base/manifest').cat
22
24
  try {
23
25
  await buildPackage('@companion-module/base', validateManifest, 'connection', '>=1.4.0 <3.0.0', {
24
26
  ignoreLicenseRules: ignoreLicenseRules(argv),
27
+ ignorePrebuildRules: ignorePrebuildRules(argv),
25
28
  });
26
29
  }
27
30
  catch (e) {
28
- if (e instanceof LicensePolicyError)
31
+ if (e instanceof LicensePolicyError || e instanceof PrebuildCoverageError)
29
32
  process.exitCode = 1;
30
33
  else
31
34
  throw e;
@@ -3,27 +3,30 @@
3
3
  import 'zx/globals';
4
4
  import { buildPackage } from './lib/build-util.js';
5
5
  import { LicensePolicyError } from './lib/license-warning-util.js';
6
- import { ignoreLicenseRules } from './lib/cli-options.js';
6
+ import { PrebuildCoverageError } from './lib/prebuild-check-util.js';
7
+ import { ignoreLicenseRules, ignorePrebuildRules } from './lib/cli-options.js';
7
8
  if (process.platform === 'win32') {
8
9
  usePowerShell(); // to enable powershell
9
10
  }
10
11
  if (argv.help) {
11
- console.log('Usage: companion-surface-build [--dev] [--prerelease] [--ignore-license-rules]');
12
+ console.log('Usage: companion-surface-build [--dev] [--prerelease] [--ignore-license-rules] [--ignore-prebuild-rules]');
12
13
  console.log('Builds the companion surface module');
13
14
  console.log(' --dev: Build in development mode. This will not minify the code, making it easier to debug.');
14
15
  console.log(' --prerelease: Build in prerelease mode. This gets added as metadata to the manifest');
15
16
  console.log(' --output <filename>: Output to a specific filename, without a file extension');
16
17
  console.log(' --ignore-license-rules: Report license policy issues as warnings instead of failing');
18
+ console.log(' --ignore-prebuild-rules: Report native libraries missing binaries for a required platform as warnings instead of failing');
17
19
  process.exit(0);
18
20
  }
19
21
  const { validateSurfaceManifest } = await import('@companion-surface/base');
20
22
  try {
21
23
  await buildPackage('@companion-surface/base', validateSurfaceManifest, 'surface', '>=1.0.0 <2.0.0', {
22
24
  ignoreLicenseRules: ignoreLicenseRules(argv),
25
+ ignorePrebuildRules: ignorePrebuildRules(argv),
23
26
  });
24
27
  }
25
28
  catch (e) {
26
- if (e instanceof LicensePolicyError)
29
+ if (e instanceof LicensePolicyError || e instanceof PrebuildCoverageError)
27
30
  process.exitCode = 1;
28
31
  else
29
32
  throw e;
@@ -9,4 +9,5 @@ export declare function enforceLegalInventory(inventory: LegalInventory, options
9
9
  }): void;
10
10
  export declare function buildPackage<M>(frameworkPackageName: string, validateManifest: (manifest: M, looseChecks: boolean) => void, moduleType: ModuleType, versionRange: string, options?: {
11
11
  ignoreLicenseRules?: boolean;
12
+ ignorePrebuildRules?: boolean;
12
13
  }): Promise<void>;
@@ -10,6 +10,7 @@ import { collectInstalledPackages, collectMetafilePackages, createLegalInventory
10
10
  import { createEsbuildOptions, loadModuleBuildDefinition } from './bundle-util.js';
11
11
  import { resolveExternalDependencies } from './external-install-util.js';
12
12
  import { enforceLicensePolicy } from './license-warning-util.js';
13
+ import { checkPrebuildCoverage, enforcePrebuildCoverage } from './prebuild-check-util.js';
13
14
  function toSanitizedDirname(name) {
14
15
  return name.replace(/[^a-zA-Z0-9-\.]/g, '-').replace(/[-+]/g, '-');
15
16
  }
@@ -176,6 +177,9 @@ export async function buildPackage(frameworkPackageName, validateManifest, modul
176
177
  }
177
178
  }
178
179
  }
180
+ // Ensure every native library has binaries for all the platforms we need to support
181
+ const prebuildReport = await checkPrebuildCoverage(packageBaseDir);
182
+ enforcePrebuildCoverage(prebuildReport, { ignorePrebuildRules: options.ignorePrebuildRules });
179
183
  const shippedPackages = [...metafilePackages.packages];
180
184
  if (Object.keys(packageJson.dependencies).length) {
181
185
  shippedPackages.push(...(await collectInstalledPackages(path.join(packageBaseDir, 'node_modules'))));
@@ -185,7 +189,7 @@ export async function buildPackage(frameworkPackageName, validateManifest, modul
185
189
  console.warn(`License inventory: ${diagnostic}`);
186
190
  }
187
191
  await writeLegalArtifacts(packageBaseDir, legalInventory);
188
- enforceLegalInventory(legalInventory, options);
192
+ enforceLegalInventory(legalInventory, { ignoreLicenseRules: options.ignoreLicenseRules });
189
193
  // Create tgz of the build
190
194
  let tgzFile = toSanitizedDirname(`${manifestJson.id}-${manifestJson.version}`);
191
195
  if (typeof argv['output'] === 'string') {
@@ -1 +1,2 @@
1
1
  export declare function ignoreLicenseRules(argv: Record<string, unknown>): boolean;
2
+ export declare function ignorePrebuildRules(argv: Record<string, unknown>): boolean;
@@ -2,3 +2,7 @@ export function ignoreLicenseRules(argv) {
2
2
  const value = argv['ignore-license-rules'];
3
3
  return value === true || value === 'true';
4
4
  }
5
+ export function ignorePrebuildRules(argv) {
6
+ const value = argv['ignore-prebuild-rules'];
7
+ return value === true || value === 'true';
8
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The platforms that every native addon in a module package must have a binary for.
3
+ * Note: darwin-x64 is expected to be dropped in the future
4
+ */
5
+ export declare const REQUIRED_PREBUILD_TARGETS: readonly string[];
6
+ export type NativeAddonKind = 'prebuilds' | 'platform-package' | 'loose';
7
+ export interface NativeAddonReport {
8
+ /** Identifies the addon these binaries belong to */
9
+ key: string;
10
+ kind: NativeAddonKind;
11
+ /** The binaries found for this addon, relative to the package dir */
12
+ files: string[];
13
+ /** The platform-arch targets the binaries were compiled for */
14
+ targets: string[];
15
+ /** Required targets which no binary covers */
16
+ missing: string[];
17
+ /** Binaries whose platform could not be determined */
18
+ unrecognised: string[];
19
+ /** Advice on why this could be failing */
20
+ hint?: string;
21
+ /** Explains why some required targets are not needed */
22
+ note?: string;
23
+ }
24
+ export interface PrebuildReport {
25
+ addons: NativeAddonReport[];
26
+ ok: boolean;
27
+ }
28
+ export declare class PrebuildCoverageError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Determine the platform(s) and arch(s) a native binary was compiled for, by inspecting its header.
33
+ * Returns targets in the form `platform-arch`, or null if the file is not a recognised binary format
34
+ */
35
+ export declare function detectBinaryTargets(filePath: string): Promise<string[] | null>;
36
+ /**
37
+ * Find every native addon in a package and check that it has binaries for every required target
38
+ * @param packageDir The root of the package to check
39
+ * @param requiredTargets Override the targets which must be covered
40
+ */
41
+ export declare function checkPrebuildCoverage(packageDir: string, requiredTargets?: readonly string[]): Promise<PrebuildReport>;
42
+ /**
43
+ * Format a report into human readable lines
44
+ */
45
+ export declare function formatPrebuildReport(report: PrebuildReport): string[];
46
+ /**
47
+ * Print the result of a prebuild check, and throw if any required targets are missing
48
+ */
49
+ export declare function enforcePrebuildCoverage(report: PrebuildReport, options?: {
50
+ ignorePrebuildRules?: boolean;
51
+ stdout?: Pick<NodeJS.WriteStream, 'write'>;
52
+ stderr?: Pick<NodeJS.WriteStream, 'write'>;
53
+ }): void;
@@ -0,0 +1,453 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ /**
4
+ * The platforms that every native addon in a module package must have a binary for.
5
+ * Note: darwin-x64 is expected to be dropped in the future
6
+ */
7
+ export const REQUIRED_PREBUILD_TARGETS = [
8
+ 'linux-x64',
9
+ 'linux-arm64',
10
+ 'win32-x64',
11
+ 'win32-arm64',
12
+ 'darwin-x64',
13
+ 'darwin-arm64',
14
+ ];
15
+ const KNOWN_PLATFORMS = new Set(['linux', 'win32', 'darwin', 'freebsd', 'openbsd', 'android', 'sunos', 'aix', 'netbsd']);
16
+ // Tokens which appear at the end of the names of per-platform packages, eg `@napi-rs/canvas-linux-x64-gnu`
17
+ const PLATFORM_PACKAGE_SUFFIX_TOKENS = new Set([
18
+ ...KNOWN_PLATFORMS,
19
+ 'windows',
20
+ 'macos',
21
+ 'x64',
22
+ 'x86_64',
23
+ 'arm64',
24
+ 'aarch64',
25
+ 'ia32',
26
+ 'x86',
27
+ 'arm',
28
+ 'armv7',
29
+ 'gnu',
30
+ 'musl',
31
+ 'msvc',
32
+ 'gnueabihf',
33
+ 'musleabihf',
34
+ 'eabi',
35
+ 'gnueabi',
36
+ 'musleabi',
37
+ 'gnux32',
38
+ 'mingw',
39
+ 'riscv64',
40
+ 'ppc64',
41
+ 'ppc64le',
42
+ 's390x',
43
+ 'loong64',
44
+ 'mips64el',
45
+ 'wasm32',
46
+ 'wasi',
47
+ 'universal',
48
+ ]);
49
+ const HEADER_READ_SIZE = 4096;
50
+ export class PrebuildCoverageError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = 'PrebuildCoverageError';
54
+ }
55
+ }
56
+ function elfArch(machine) {
57
+ switch (machine) {
58
+ case 0x3e:
59
+ return 'x64';
60
+ case 0xb7:
61
+ return 'arm64';
62
+ case 0x03:
63
+ return 'ia32';
64
+ case 0x28:
65
+ return 'arm';
66
+ default:
67
+ return `unknown(0x${machine.toString(16)})`;
68
+ }
69
+ }
70
+ function peArch(machine) {
71
+ switch (machine) {
72
+ case 0x8664:
73
+ return 'x64';
74
+ case 0xaa64:
75
+ return 'arm64';
76
+ case 0x014c:
77
+ return 'ia32';
78
+ case 0x01c4:
79
+ return 'arm';
80
+ default:
81
+ return `unknown(0x${machine.toString(16)})`;
82
+ }
83
+ }
84
+ function machoArch(cpuType) {
85
+ switch (cpuType) {
86
+ case 0x01000007:
87
+ return 'x64';
88
+ case 0x0100000c:
89
+ return 'arm64';
90
+ case 0x00000007:
91
+ return 'ia32';
92
+ case 0x0000000c:
93
+ return 'arm';
94
+ default:
95
+ return `unknown(0x${cpuType.toString(16)})`;
96
+ }
97
+ }
98
+ /**
99
+ * Determine the platform(s) and arch(s) a native binary was compiled for, by inspecting its header.
100
+ * Returns targets in the form `platform-arch`, or null if the file is not a recognised binary format
101
+ */
102
+ export async function detectBinaryTargets(filePath) {
103
+ const handle = await fs.open(filePath, 'r');
104
+ try {
105
+ const readAt = async (position, length) => {
106
+ const buffer = Buffer.alloc(length);
107
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
108
+ return buffer.subarray(0, bytesRead);
109
+ };
110
+ const header = await readAt(0, HEADER_READ_SIZE);
111
+ if (header.length < 8)
112
+ return null;
113
+ // ELF (linux)
114
+ if (header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) {
115
+ if (header.length < 20)
116
+ return null;
117
+ const littleEndian = header[5] !== 2;
118
+ const machine = littleEndian ? header.readUInt16LE(18) : header.readUInt16BE(18);
119
+ const osAbi = header[7];
120
+ const platform = osAbi === 9 ? 'freebsd' : osAbi === 12 ? 'openbsd' : osAbi === 2 ? 'netbsd' : 'linux';
121
+ return [`${platform}-${elfArch(machine)}`];
122
+ }
123
+ // PE (windows)
124
+ if (header[0] === 0x4d && header[1] === 0x5a) {
125
+ if (header.length < 0x40)
126
+ return null;
127
+ const peOffset = header.readUInt32LE(0x3c);
128
+ const peHeader = peOffset + 6 <= header.length ? header.subarray(peOffset) : await readAt(peOffset, 6);
129
+ if (peHeader.length < 6 || peHeader.readUInt32BE(0) !== 0x50450000)
130
+ return null;
131
+ return [`win32-${peArch(peHeader.readUInt16LE(4))}`];
132
+ }
133
+ // Mach-O (macos), single architecture
134
+ const magicLE = header.readUInt32LE(0);
135
+ if (magicLE === 0xfeedfacf || magicLE === 0xfeedface) {
136
+ return [`darwin-${machoArch(header.readUInt32LE(4))}`];
137
+ }
138
+ // Mach-O (macos), universal/fat binary containing multiple architectures
139
+ const magicBE = header.readUInt32BE(0);
140
+ if (magicBE === 0xcafebabe || magicBE === 0xcafebabf) {
141
+ const entrySize = magicBE === 0xcafebabf ? 32 : 20;
142
+ const count = header.readUInt32BE(4);
143
+ const entries = 8 + count * entrySize <= header.length ? header : await readAt(0, 8 + count * entrySize);
144
+ const targets = [];
145
+ for (let i = 0; i < count; i++) {
146
+ const offset = 8 + i * entrySize;
147
+ if (offset + 4 > entries.length)
148
+ return null;
149
+ targets.push(`darwin-${machoArch(entries.readUInt32BE(offset))}`);
150
+ }
151
+ return targets.length ? targets : null;
152
+ }
153
+ return null;
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ }
159
+ async function findNativeFiles(rootDir) {
160
+ const binaries = [];
161
+ const packageJsons = [];
162
+ const walk = async (dir) => {
163
+ const entries = await fs.readdir(dir, { withFileTypes: true });
164
+ for (const entry of entries) {
165
+ const fullPath = path.join(dir, entry.name);
166
+ if (entry.isDirectory()) {
167
+ await walk(fullPath);
168
+ }
169
+ else if (entry.isFile() && entry.name.endsWith('.node')) {
170
+ binaries.push(fullPath);
171
+ }
172
+ else if (entry.isFile() && entry.name === 'package.json') {
173
+ packageJsons.push(fullPath);
174
+ }
175
+ }
176
+ };
177
+ await walk(rootDir);
178
+ return { binaries: binaries.sort(), packageJsons: packageJsons.sort() };
179
+ }
180
+ async function readPackageJson(dir, cache) {
181
+ let json = cache.get(dir);
182
+ if (json === undefined) {
183
+ try {
184
+ json = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8'));
185
+ }
186
+ catch {
187
+ json = null;
188
+ }
189
+ cache.set(dir, json);
190
+ }
191
+ return json;
192
+ }
193
+ /**
194
+ * Find the nearest package.json for a file, without leaving the root dir
195
+ */
196
+ async function findOwningPackageJson(rootDir, filePath, cache) {
197
+ let dir = path.dirname(filePath);
198
+ while (true) {
199
+ const json = await readPackageJson(dir, cache);
200
+ if (json)
201
+ return json;
202
+ if (path.resolve(dir) === path.resolve(rootDir))
203
+ return null;
204
+ const parent = path.dirname(dir);
205
+ if (parent === dir)
206
+ return null;
207
+ dir = parent;
208
+ }
209
+ }
210
+ const PLATFORM_IN_NAME_REGEX = new RegExp(`(^|[-.])(${Array.from(KNOWN_PLATFORMS).join('|')})([-.+]|$)`);
211
+ /**
212
+ * Strip the platform portion from a prebuild folder or file name.
213
+ * eg `freetype2-linux-x64` -> `freetype2`, `image-rs.linux-x64-gnu` -> `image-rs`, `darwin-x64+arm64` -> ``
214
+ */
215
+ function prebuildNamePrefix(name) {
216
+ const match = PLATFORM_IN_NAME_REGEX.exec(name);
217
+ if (!match)
218
+ return name;
219
+ return name.slice(0, match.index);
220
+ }
221
+ /**
222
+ * Strip the platform portion from the name of a per-platform package.
223
+ * eg `@napi-rs/canvas-linux-x64-gnu` -> `@napi-rs/canvas`, `@esbuild/linux-x64` -> `@esbuild`
224
+ * Returns null if the name does not look like a per-platform package
225
+ */
226
+ function platformPackageBaseName(name) {
227
+ const slash = name.indexOf('/');
228
+ const scope = name.startsWith('@') && slash !== -1 ? name.slice(0, slash + 1) : '';
229
+ const tokens = name.slice(scope.length).split('-');
230
+ let foundPlatform = false;
231
+ while (tokens.length && PLATFORM_PACKAGE_SUFFIX_TOKENS.has(tokens[tokens.length - 1].toLowerCase())) {
232
+ const token = tokens.pop().toLowerCase();
233
+ if (KNOWN_PLATFORMS.has(token) || token === 'windows' || token === 'macos')
234
+ foundPlatform = true;
235
+ }
236
+ if (!foundPlatform)
237
+ return null;
238
+ const rest = tokens.join('-');
239
+ if (rest)
240
+ return scope + rest;
241
+ return scope ? scope.slice(0, -1) : null;
242
+ }
243
+ /**
244
+ * Check a value against a package.json `os` or `cpu` style constraint, which can contain negations like `!win32`
245
+ */
246
+ function matchesConstraint(value, constraint) {
247
+ if (!Array.isArray(constraint) || constraint.length === 0)
248
+ return true;
249
+ const entries = constraint.filter((entry) => typeof entry === 'string');
250
+ if (entries.includes(`!${value}`))
251
+ return false;
252
+ const positives = entries.filter((entry) => !entry.startsWith('!'));
253
+ return positives.length === 0 || positives.includes(value);
254
+ }
255
+ function hasPlatformConstraint(pkgJson) {
256
+ return Array.isArray(pkgJson.os) || Array.isArray(pkgJson.cpu);
257
+ }
258
+ /**
259
+ * Determine the targets a package says it supports, through its `os`, `cpu` and `libc` fields
260
+ */
261
+ function packageJsonTargetFilter(pkgJson) {
262
+ return (target) => {
263
+ const [platform, arch] = target.split('-');
264
+ if (!matchesConstraint(platform, pkgJson.os) || !matchesConstraint(arch, pkgJson.cpu))
265
+ return false;
266
+ // We only support glibc on linux
267
+ if (platform === 'linux' && !matchesConstraint('glibc', pkgJson.libc))
268
+ return false;
269
+ return true;
270
+ };
271
+ }
272
+ const PLATFORM_PACKAGE_HINT = 'This library ships its binaries as separate packages per platform, and only the one(s) for the build machine got installed.';
273
+ const LOOSE_BINARY_HINT = 'This binary is not in a prebuilds folder, so was most likely compiled or downloaded for the build machine only (eg by node-gyp, prebuild-install or node-pre-gyp).';
274
+ /**
275
+ * Find every native addon in a package and check that it has binaries for every required target
276
+ * @param packageDir The root of the package to check
277
+ * @param requiredTargets Override the targets which must be covered
278
+ */
279
+ export async function checkPrebuildCoverage(packageDir, requiredTargets = REQUIRED_PREBUILD_TARGETS) {
280
+ const { binaries, packageJsons } = await findNativeFiles(packageDir);
281
+ const packageJsonCache = new Map();
282
+ const groups = new Map();
283
+ const getGroup = (key, kind) => {
284
+ let group = groups.get(key);
285
+ if (!group) {
286
+ group = { kind, files: [], targets: new Set(), unrecognised: [] };
287
+ groups.set(key, group);
288
+ }
289
+ return group;
290
+ };
291
+ // Libraries which ship per-platform packages (eg napi-rs) list them as optionalDependencies, and the package manager installs only the matching ones
292
+ for (const packageJsonPath of packageJsons) {
293
+ const pkgJson = await readPackageJson(path.dirname(packageJsonPath), packageJsonCache);
294
+ if (!pkgJson)
295
+ continue;
296
+ // Packages it expects to have installed for each platform
297
+ if (pkgJson.optionalDependencies && typeof pkgJson.optionalDependencies === 'object') {
298
+ for (const depName of Object.keys(pkgJson.optionalDependencies)) {
299
+ const baseName = platformPackageBaseName(depName);
300
+ if (baseName)
301
+ getGroup(baseName, 'platform-package');
302
+ }
303
+ }
304
+ // An installed per-platform package
305
+ if (typeof pkgJson.name === 'string' && hasPlatformConstraint(pkgJson)) {
306
+ const baseName = platformPackageBaseName(pkgJson.name);
307
+ if (baseName) {
308
+ const group = getGroup(baseName, 'platform-package');
309
+ const filter = packageJsonTargetFilter(pkgJson);
310
+ for (const target of requiredTargets) {
311
+ if (filter(target))
312
+ group.targets.add(target);
313
+ }
314
+ }
315
+ }
316
+ }
317
+ for (const filePath of binaries) {
318
+ const relativePath = path.relative(packageDir, filePath).split(path.sep).join('/');
319
+ const segments = relativePath.split('/');
320
+ const owningPackage = await findOwningPackageJson(packageDir, filePath, packageJsonCache);
321
+ const platformBaseName = owningPackage && typeof owningPackage.name === 'string' && hasPlatformConstraint(owningPackage)
322
+ ? platformPackageBaseName(owningPackage.name)
323
+ : null;
324
+ let group;
325
+ const prebuildsIndex = segments.lastIndexOf('prebuilds');
326
+ if (platformBaseName) {
327
+ group = getGroup(platformBaseName, 'platform-package');
328
+ }
329
+ else if (prebuildsIndex !== -1 && prebuildsIndex < segments.length - 1) {
330
+ // Some form of `prebuilds/<platform-ish>/<name>.node` or `prebuilds/<platform-ish>.node`
331
+ const prebuildsDir = segments.slice(0, prebuildsIndex + 1).join('/');
332
+ const prefix = prebuildNamePrefix(segments[prebuildsIndex + 1].replace(/\.node$/, ''));
333
+ group = getGroup(prefix ? `${prebuildsDir}/${prefix}` : prebuildsDir, 'prebuilds');
334
+ group.prebuildsDir = prebuildsDir;
335
+ }
336
+ else {
337
+ group = getGroup(relativePath, 'loose');
338
+ }
339
+ // A package which declares it only works on some platforms (eg fsevents) only needs binaries for those
340
+ if (owningPackage && !platformBaseName && hasPlatformConstraint(owningPackage) && !group.filter) {
341
+ group.filter = packageJsonTargetFilter(owningPackage);
342
+ group.note = `${String(owningPackage.name)} declares itself as only supporting some platforms`;
343
+ }
344
+ group.files.push(relativePath);
345
+ const targets = await detectBinaryTargets(filePath);
346
+ if (targets) {
347
+ // The binary header can't easily tell glibc/musl/android apart, so use the naming to exclude the unsupported ones
348
+ const nameTokens = new Set([
349
+ ...segments.slice(prebuildsIndex !== -1 ? prebuildsIndex + 1 : segments.length - 1),
350
+ platformBaseName ? String(owningPackage?.name) : '',
351
+ ]
352
+ .join('/')
353
+ .toLowerCase()
354
+ .split(/[^a-z0-9]+/));
355
+ const isOtherLinux = ['musl', 'android', 'musleabihf', 'musleabi', 'ohos', 'openharmony'].some((token) => nameTokens.has(token));
356
+ for (const target of targets) {
357
+ if (isOtherLinux && target.startsWith('linux-'))
358
+ continue;
359
+ group.targets.add(target);
360
+ }
361
+ }
362
+ else {
363
+ group.unrecognised.push(relativePath);
364
+ }
365
+ }
366
+ const missingFor = (group) => requiredTargets.filter((target) => (!group.filter || group.filter(target)) && !group.targets.has(target));
367
+ // Some libraries have extra addons for specific platforms alongside a complete one (eg node-hid has HID_hidraw for linux)
368
+ // When a sibling in the same prebuilds folder is complete, assume any others are intentionally limited to the platforms they have binaries for
369
+ for (const [key, group] of groups) {
370
+ if (group.kind !== 'prebuilds' || group.filter)
371
+ continue;
372
+ const completeSibling = Array.from(groups).find(([, other]) => other !== group &&
373
+ other.prebuildsDir === group.prebuildsDir &&
374
+ missingFor(other).length === 0 &&
375
+ other.unrecognised.length === 0);
376
+ if (!completeSibling || missingFor(group).length === 0)
377
+ continue;
378
+ const platforms = new Set(Array.from(group.targets).map((target) => target.split('-')[0]));
379
+ group.filter = (target) => platforms.has(target.split('-')[0]);
380
+ group.note = `${key} is assumed to be specific to ${Array.from(platforms).sort().join(', ')}, as ${completeSibling[0]} covers every platform`;
381
+ }
382
+ const addons = [];
383
+ for (const [key, group] of groups) {
384
+ const missing = missingFor(group);
385
+ let hint;
386
+ if (group.kind === 'platform-package')
387
+ hint = PLATFORM_PACKAGE_HINT;
388
+ else if (group.kind === 'loose')
389
+ hint = LOOSE_BINARY_HINT;
390
+ addons.push({
391
+ key,
392
+ kind: group.kind,
393
+ files: group.files,
394
+ targets: Array.from(group.targets).sort(),
395
+ missing,
396
+ unrecognised: group.unrecognised,
397
+ hint: missing.length || group.unrecognised.length ? hint : undefined,
398
+ note: group.note,
399
+ });
400
+ }
401
+ addons.sort((a, b) => a.key.localeCompare(b.key));
402
+ return {
403
+ addons,
404
+ ok: addons.every((addon) => addon.missing.length === 0 && addon.unrecognised.length === 0),
405
+ };
406
+ }
407
+ /**
408
+ * Format a report into human readable lines
409
+ */
410
+ export function formatPrebuildReport(report) {
411
+ const lines = [];
412
+ for (const addon of report.addons) {
413
+ const ok = addon.missing.length === 0 && addon.unrecognised.length === 0;
414
+ lines.push(`${ok ? '✔' : '✘'} ${addon.key}: found ${addon.targets.join(', ') || 'nothing'}`);
415
+ if (addon.note)
416
+ lines.push(` note: ${addon.note}`);
417
+ if (addon.missing.length)
418
+ lines.push(` missing: ${addon.missing.join(', ')}`);
419
+ for (const file of addon.unrecognised)
420
+ lines.push(` unrecognised binary: ${file}`);
421
+ if (!ok) {
422
+ for (const file of addon.files)
423
+ lines.push(` file: ${file}`);
424
+ if (addon.hint)
425
+ lines.push(` ${addon.hint}`);
426
+ }
427
+ }
428
+ return lines;
429
+ }
430
+ /**
431
+ * Print the result of a prebuild check, and throw if any required targets are missing
432
+ */
433
+ export function enforcePrebuildCoverage(report, options = {}) {
434
+ const stdout = options.stdout ?? process.stdout;
435
+ const stderr = options.stderr ?? process.stderr;
436
+ if (report.addons.length === 0) {
437
+ stdout.write('Native prebuilds: none found\n');
438
+ return;
439
+ }
440
+ const lines = formatPrebuildReport(report);
441
+ if (report.ok) {
442
+ stdout.write(`Native prebuilds:\n${lines.map((line) => ` ${line}\n`).join('')}`);
443
+ return;
444
+ }
445
+ const failed = report.addons.filter((addon) => addon.missing.length || addon.unrecognised.length).length;
446
+ const label = options.ignorePrebuildRules ? 'PREBUILD WARNING' : 'PREBUILD ERROR';
447
+ stderr.write(`${label}: ${failed} native ${failed === 1 ? 'library is' : 'libraries are'} missing binaries for required platforms (${REQUIRED_PREBUILD_TARGETS.join(', ')})\n`);
448
+ stderr.write(lines.map((line) => ` ${line}\n`).join(''));
449
+ if (!options.ignorePrebuildRules) {
450
+ stderr.write('Pass --ignore-prebuild-rules to build anyway.\n');
451
+ throw new PrebuildCoverageError(`Prebuild validation failed for ${failed} native ${failed === 1 ? 'library' : 'libraries'}`);
452
+ }
453
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-module/tools",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {