Files
crossdesk/apps/cli/scripts/pack-npm.cjs
T

203 lines
12 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const cliRoot = path.resolve(__dirname, '..');
const root = path.resolve(cliRoot, '../..');
const project = require('../package.json');
const platforms = require('../npm/platforms.json');
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options });
}
function copy(from, to) {
fs.cpSync(path.join(root, from), to, {
recursive: true,
filter: (entry) => !['.git', '.xmake', '.vscode', 'build', 'out', 'node_modules', 'tests', 'test'].includes(path.basename(entry)),
});
}
function json(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function tarballName(name, version) {
// npm pack writes @scope/name as scope-name-version.tgz.
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
}
function inspect(binary, target) {
const data = fs.readFileSync(binary);
const info = { target, sha256: crypto.createHash('sha256').update(data).digest('hex') };
if (process.platform === 'darwin') {
const cpu = process.arch === 'arm64' ? 0x100000c : 0x1000007;
if (data.length < 32 || data.readUInt32LE(0) !== 0xfeedfacf ||
data.readUInt32LE(4) !== cpu || data.readUInt32LE(12) !== 2) {
throw new Error('Expected a Mach-O executable matching the current CPU.');
}
info.dependencies = run('otool', ['-L', binary]).split('\n').slice(1)
.map((line) => line.trim().split(' (')[0]).filter(Boolean);
for (const dependency of info.dependencies) {
if (!dependency.startsWith('/usr/lib/') && !dependency.startsWith('/System/Library/'))
throw new Error(`Non-system macOS library must be bundled before packaging: ${dependency}`);
}
const commands = run('otool', ['-l', binary]);
info.minimumOS = commands.match(/\bminos\s+([\d.]+)/)?.[1] ||
commands.match(/LC_VERSION_MIN_MACOSX[\s\S]*?\bversion\s+([\d.]+)/)?.[1];
if (!info.minimumOS) throw new Error('Cannot determine macOS deployment target.');
run('codesign', ['--verify', binary]);
} else if (process.platform === 'linux') {
const machine = process.arch === 'arm64' ? 183 : 62;
if (data.length < 64 || data.subarray(0, 4).toString('hex') !== '7f454c46' ||
data[4] !== 2 || data[5] !== 1 || data.readUInt16LE(18) !== machine)
throw new Error('Expected a 64-bit ELF executable matching the current CPU.');
if (!process.report.getReport().header.glibcVersionRuntime)
throw new Error('The Linux npm packages currently target glibc, not musl.');
info.dependencies = run('ldd', [binary]).trim().split('\n').map((line) => line.trim());
const system = /^(linux-vdso|ld-linux|lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|z|resolv)\.)/;
for (const dependency of info.dependencies) {
const name = path.basename(dependency.split(/\s/)[0]);
if (dependency.includes('not found') || !system.test(name))
throw new Error(`Unbundled Linux dependency: ${dependency}`);
}
const symbols = run('objdump', ['-T', binary]);
info.glibc = [...symbols.matchAll(/\bGLIBC_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
info.glibcxx = [...symbols.matchAll(/\bGLIBCXX_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
} else {
const offset = data.length >= 64 ? data.readUInt32LE(60) : data.length;
const machine = 0x8664;
if (data.toString('ascii', 0, 2) !== 'MZ' || offset + 6 > data.length ||
data.readUInt32LE(offset) !== 0x4550 || data.readUInt16LE(offset + 4) !== machine)
throw new Error('Expected a Windows PE executable matching the current CPU.');
info.dependencies = [...run('dumpbin.exe', ['/DEPENDENTS', binary])
.matchAll(/^\s+([\w.-]+\.dll)\s*$/gim)].map((match) => match[1]);
if (!info.dependencies.length) throw new Error('Cannot inspect Windows DLL dependencies.');
for (const dependency of info.dependencies) {
if (/^(msvcp|vcruntime|concrt)/i.test(dependency) ||
(!/^(api-ms-|ext-ms-)/i.test(dependency) &&
!fs.existsSync(path.join(process.env.SystemRoot, 'System32', dependency))))
throw new Error(`Unbundled Windows runtime: ${dependency}. Build with static runtimes (MT).`);
}
info.minimumOS = 'Windows 10 (VT console)';
}
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
return info;
}
function pack(directory, output) {
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
const expected = tarballName(manifest.name, manifest.version);
if (fs.existsSync(path.join(output, expected))) throw new Error(`Output already exists: ${expected}`);
const args = ['pack', '--json', '--ignore-scripts', '--pack-destination', output];
if (process.platform === 'win32' && !process.env.npm_execpath)
throw new Error('On Windows, run this script through npm run pack:npm.');
const result = process.env.npm_execpath
? run(process.execPath, [process.env.npm_execpath, ...args], { cwd: directory })
: run('npm', args, { cwd: directory });
// npm 12 uses a name-keyed object instead of the earlier array format.
const parsed = JSON.parse(result);
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed);
if (entries.length !== 1) throw new Error('npm pack must produce exactly one package.');
const [{ filename, size }] = entries;
if (filename !== expected) throw new Error(`Unexpected npm tarball name: ${filename}; expected ${expected}`);
return { filename, size };
}
let staging;
const created = [];
try {
const args = process.argv.slice(2);
let output = path.join(root, 'build', 'npm');
let binary;
for (let i = 0; i < args.length; i += 2) {
if (!args[i + 1]) throw new Error(`Missing value for ${args[i]}`);
if (args[i] === '--out-dir') output = path.resolve(args[i + 1]);
else if (args[i] === '--binary') binary = path.resolve(args[i + 1]);
else throw new Error(`Unknown option: ${args[i]}`);
}
const target = `${process.platform}-${process.arch}`;
const platform = platforms[target];
if (!platform) throw new Error(`No packaging target for ${target}`);
const packageNames = Object.values(platforms).map(({ packageName }) => packageName);
if (packageNames.some((name) => typeof name !== 'string' || name.length > 214 ||
!/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(name)))
throw new Error('Every npm platform must declare a valid scoped packageName.');
if (new Set(packageNames).size !== packageNames.length)
throw new Error('Each npm platform must have a distinct packageName.');
const version = fs.readFileSync(path.join(cliRoot, 'xmake.lua'), 'utf8').match(/set_version\("([^"]+)"\)/)?.[1];
if (version !== project.version) throw new Error('apps/cli/package.json and apps/cli/xmake.lua versions must match.');
for (const name of [project.name, platform.packageName]) {
const filename = tarballName(name, version);
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
}
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
const info = inspect(binary, target);
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'crossdesk-npm-'));
fs.mkdirSync(output, { recursive: true });
const native = path.join(staging, 'native');
fs.mkdirSync(path.join(native, 'bin'), { recursive: true });
fs.copyFileSync(binary, path.join(native, 'bin', executable));
fs.chmodSync(path.join(native, 'bin', executable), 0o755);
copy('LICENSE', path.join(native, 'LICENSE'));
// Include the controller and patched MiniRTC sources used by this checkout.
// External Xmake dependency source/licenses still belong to the release's
// dependency bundle; see source/apps/cli/docs/source-provenance.md.
// Keep desktop build definitions for Xmake's initial option discovery;
// desktop application sources and resources are intentionally not bundled.
for (const entry of ['apps/cli', 'apps/desktop/xmake',
'libs/controller/include',
'libs/controller/src', 'libs/terminal', 'libs/wire/include',
'libs/wire/src', 'deps/submodules/minirtc',
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua',
'LICENSE', '.gitmodules'])
copy(entry, path.join(native, 'source', entry));
json(path.join(native, 'build-info.json'), { version, ...info });
fs.writeFileSync(path.join(native, 'README.md'), `# ${platform.packageName}\n\nNative ${target} dependency for [${project.name}](https://www.npmjs.com/package/${project.name}) ${version}, published under the ${platform.packageName.split('/')[0]} scope.\n\nInstall the cross-platform entry package with \`npm install -g ${project.name}\` or run \`npx ${project.name} doctor --json\`. This platform package contains the native binary and does not register a shell command.\n\nSource repository: [CrossDesk](${project.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}). Report problems at ${project.bugs.url}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
const metadata = {
license: project.license, author: project.author, repository: project.repository,
homepage: project.homepage, bugs: project.bugs,
publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' },
};
json(path.join(native, 'package.json'), {
...metadata, name: platform.packageName, version, description: `${project.description} (${target})`,
os: [platform.os], cpu: [platform.cpu],
...(platform.libc ? { libc: [platform.libc] } : {}),
files: ['bin', 'build-info.json', 'source', 'LICENSE', 'README.md'],
});
const launcher = path.join(staging, 'launcher');
fs.mkdirSync(launcher);
for (const entry of ['npm', 'docs', 'README.md']) copy(`apps/cli/${entry}`, path.join(launcher, entry));
copy('LICENSE', path.join(launcher, 'LICENSE'));
fs.chmodSync(path.join(launcher, 'npm', 'cli.cjs'), 0o755);
json(path.join(launcher, 'package.json'), {
...metadata, name: project.name, version, description: project.description, engines: project.engines,
bin: { 'crossdesk-cli': 'npm/cli.cjs' },
os: ['darwin', 'linux', 'win32'], cpu: ['arm64', 'x64'],
files: ['npm', 'docs', 'README.md', 'LICENSE'],
optionalDependencies: Object.fromEntries(packageNames.map((name) => [name, version])),
});
// Finish both archives before publishing either one to the output directory.
const archives = path.join(staging, 'archives');
fs.mkdirSync(archives);
const packages = [pack(native, archives), pack(launcher, archives)];
for (const { filename } of packages) {
const destination = path.join(output, filename);
fs.copyFileSync(path.join(archives, filename), destination, fs.constants.COPYFILE_EXCL);
created.push(destination);
}
for (const { filename, size } of packages)
console.log(`${path.join(output, filename)} (${(size / 1024 / 1024).toFixed(2)} MiB)`);
console.log(`Native requirements: ${JSON.stringify(info)}`);
} catch (error) {
for (const file of created) fs.rmSync(file, { force: true });
console.error(`pack:npm: ${error.message}`);
process.exitCode = 1;
} finally {
if (staging) fs.rmSync(staging, { recursive: true, force: true });
}