347 lines
10 KiB
JavaScript
347 lines
10 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const distDirectory = join(repositoryRoot, 'dist');
|
|
const releaseDirectory = join(repositoryRoot, 'release');
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(repositoryRoot, 'package.json'), 'utf8')
|
|
);
|
|
const artifactName = `xslt-tools-${packageJson.version}`;
|
|
const archivePath = join(releaseDirectory, `${artifactName}.zip`);
|
|
const checksumPath = join(releaseDirectory, `${artifactName}.sha256`);
|
|
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
|
|
|
export function listFiles(directory) {
|
|
return readdirSync(directory, { withFileTypes: true })
|
|
.sort((left, right) => compareText(left.name, right.name))
|
|
.flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) return listFiles(path);
|
|
if (entry.isFile()) return [path];
|
|
throw new Error(
|
|
`Refusing to package symlink or special filesystem entry: ${path}`
|
|
);
|
|
});
|
|
}
|
|
|
|
function zipPath(path) {
|
|
return path.split(sep).join('/');
|
|
}
|
|
|
|
export function collectReleaseEntries() {
|
|
if (!existsSync(join(distDirectory, 'index.html'))) {
|
|
throw new Error('dist/index.html is missing. Run `npm run build` first.');
|
|
}
|
|
|
|
if (!existsSync(join(distDirectory, 'toolbox-app.json'))) {
|
|
throw new Error(
|
|
'dist/toolbox-app.json is missing. Generate and build the toolbox manifest first.'
|
|
);
|
|
}
|
|
|
|
const entries = listFiles(distDirectory).map((path) => ({
|
|
name: zipPath(relative(distDirectory, path)),
|
|
data: readFileSync(path),
|
|
}));
|
|
|
|
entries.push(
|
|
{
|
|
name: 'CHANGELOG.md',
|
|
data: readFileSync(join(repositoryRoot, 'CHANGELOG.md')),
|
|
},
|
|
{
|
|
name: 'LICENSE',
|
|
data: readFileSync(join(repositoryRoot, 'LICENSE')),
|
|
},
|
|
{
|
|
name: 'LICENSES/xslt-tools-LICENSE.txt',
|
|
data: readFileSync(join(repositoryRoot, 'LICENSE')),
|
|
},
|
|
{
|
|
name: 'LICENSES/SaxonJS-LICENSE.txt',
|
|
data: readFileSync(
|
|
join(repositoryRoot, 'public/vendor/saxon/LICENSE.txt')
|
|
),
|
|
},
|
|
{
|
|
name: 'THIRD_PARTY_NOTICES.md',
|
|
data: readFileSync(join(repositoryRoot, 'THIRD_PARTY_NOTICES.md')),
|
|
},
|
|
{
|
|
name: 'SOURCE.md',
|
|
data: readFileSync(join(repositoryRoot, 'SOURCE.md')),
|
|
}
|
|
);
|
|
|
|
entries.push(...collectRuntimeDependencyLicenseEntries());
|
|
|
|
return entries.sort((left, right) => compareText(left.name, right.name));
|
|
}
|
|
|
|
export function collectRuntimeDependencyLicenseEntries() {
|
|
const packages = collectRuntimePackages();
|
|
const entries = [];
|
|
|
|
for (const runtimePackage of packages) {
|
|
const legalFiles = readdirSync(runtimePackage.directory, {
|
|
withFileTypes: true,
|
|
})
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile() &&
|
|
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
|
|
)
|
|
.sort((left, right) => compareText(left.name, right.name));
|
|
|
|
if (legalFiles.length === 0) {
|
|
throw new Error(
|
|
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no licence file.`
|
|
);
|
|
}
|
|
|
|
const directoryName = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
|
|
for (const legalFile of legalFiles) {
|
|
entries.push({
|
|
name: `LICENSES/npm/${directoryName}/${basename(legalFile.name)}`,
|
|
data: readFileSync(join(runtimePackage.directory, legalFile.name)),
|
|
});
|
|
}
|
|
}
|
|
|
|
return entries.sort((left, right) => compareText(left.name, right.name));
|
|
}
|
|
|
|
function collectRuntimePackages() {
|
|
const queue = Object.keys(packageJson.dependencies ?? {}).map((name) => ({
|
|
name,
|
|
fromDirectory: repositoryRoot,
|
|
}));
|
|
const visited = new Set();
|
|
const packages = [];
|
|
|
|
while (queue.length > 0) {
|
|
const dependency = queue.shift();
|
|
const directory = findInstalledPackageDirectory(
|
|
dependency.name,
|
|
dependency.fromDirectory
|
|
);
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(directory, 'package.json'), 'utf8')
|
|
);
|
|
const identity = `${manifest.name}@${manifest.version}`;
|
|
if (visited.has(identity)) continue;
|
|
|
|
visited.add(identity);
|
|
packages.push({
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
directory,
|
|
});
|
|
|
|
const childNames = new Set([
|
|
...Object.keys(manifest.dependencies ?? {}),
|
|
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
]);
|
|
for (const name of [...childNames].sort(compareText)) {
|
|
const childDirectory = tryFindInstalledPackageDirectory(name, directory);
|
|
if (childDirectory) {
|
|
queue.push({ name, fromDirectory: directory });
|
|
}
|
|
}
|
|
}
|
|
|
|
return packages.sort((left, right) =>
|
|
compareText(
|
|
`${left.name}@${left.version}`,
|
|
`${right.name}@${right.version}`
|
|
)
|
|
);
|
|
}
|
|
|
|
function findInstalledPackageDirectory(name, fromDirectory) {
|
|
const directory = tryFindInstalledPackageDirectory(name, fromDirectory);
|
|
if (!directory) {
|
|
throw new Error(`Runtime dependency ${name} is not installed.`);
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
function tryFindInstalledPackageDirectory(name, fromDirectory) {
|
|
let current = resolve(fromDirectory);
|
|
const packageSegments = name.split('/');
|
|
|
|
while (true) {
|
|
const candidate = join(current, 'node_modules', ...packageSegments);
|
|
if (existsSync(join(candidate, 'package.json'))) {
|
|
return realpathSync(candidate);
|
|
}
|
|
|
|
const parent = dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
function safePackageName(name) {
|
|
return name.replace(/^@/, '').replaceAll('/', '--');
|
|
}
|
|
|
|
function compareText(left, right) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
const crcTable = Array.from({ length: 256 }, (_, value) => {
|
|
let result = value;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
result = (result & 1) !== 0 ? 0xedb88320 ^ (result >>> 1) : result >>> 1;
|
|
}
|
|
return result >>> 0;
|
|
});
|
|
|
|
function crc32(data) {
|
|
let result = 0xffffffff;
|
|
for (const byte of data) {
|
|
result = crcTable[(result ^ byte) & 0xff] ^ (result >>> 8);
|
|
}
|
|
return (result ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
export function createDeterministicZip(entries) {
|
|
const localRecords = [];
|
|
const centralRecords = [];
|
|
let offset = 0;
|
|
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.name, 'utf8');
|
|
const data = Buffer.from(entry.data);
|
|
const checksum = crc32(data);
|
|
|
|
const localHeader = Buffer.alloc(30);
|
|
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
localHeader.writeUInt16LE(20, 4);
|
|
localHeader.writeUInt16LE(0x0800, 6);
|
|
localHeader.writeUInt16LE(0, 8);
|
|
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 10);
|
|
localHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 12);
|
|
localHeader.writeUInt32LE(checksum, 14);
|
|
localHeader.writeUInt32LE(data.length, 18);
|
|
localHeader.writeUInt32LE(data.length, 22);
|
|
localHeader.writeUInt16LE(name.length, 26);
|
|
localHeader.writeUInt16LE(0, 28);
|
|
|
|
localRecords.push(localHeader, name, data);
|
|
|
|
const centralHeader = Buffer.alloc(46);
|
|
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
centralHeader.writeUInt16LE(20, 4);
|
|
centralHeader.writeUInt16LE(20, 6);
|
|
centralHeader.writeUInt16LE(0x0800, 8);
|
|
centralHeader.writeUInt16LE(0, 10);
|
|
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 12);
|
|
centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 14);
|
|
centralHeader.writeUInt32LE(checksum, 16);
|
|
centralHeader.writeUInt32LE(data.length, 20);
|
|
centralHeader.writeUInt32LE(data.length, 24);
|
|
centralHeader.writeUInt16LE(name.length, 28);
|
|
centralHeader.writeUInt16LE(0, 30);
|
|
centralHeader.writeUInt16LE(0, 32);
|
|
centralHeader.writeUInt16LE(0, 34);
|
|
centralHeader.writeUInt16LE(0, 36);
|
|
centralHeader.writeUInt32LE(0, 38);
|
|
centralHeader.writeUInt32LE(offset, 42);
|
|
|
|
centralRecords.push(centralHeader, name);
|
|
offset += localHeader.length + name.length + data.length;
|
|
}
|
|
|
|
const centralDirectory = Buffer.concat(centralRecords);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(0, 4);
|
|
end.writeUInt16LE(0, 6);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
end.writeUInt16LE(0, 20);
|
|
|
|
return Buffer.concat([...localRecords, centralDirectory, end]);
|
|
}
|
|
|
|
export function toDosDateTime(date) {
|
|
const year = date.getFullYear();
|
|
if (year < 1980 || year > 2107) {
|
|
throw new RangeError('ZIP timestamps must be between 1980 and 2107.');
|
|
}
|
|
|
|
return {
|
|
time:
|
|
(date.getHours() << 11) |
|
|
(date.getMinutes() << 5) |
|
|
Math.floor(date.getSeconds() / 2),
|
|
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
};
|
|
}
|
|
|
|
export function writeReleaseFiles({
|
|
archive,
|
|
archiveTarget,
|
|
checksum,
|
|
checksumTarget,
|
|
force = false,
|
|
}) {
|
|
mkdirSync(dirname(archiveTarget), { recursive: true });
|
|
mkdirSync(dirname(checksumTarget), { recursive: true });
|
|
|
|
const existingTargets = [archiveTarget, checksumTarget].filter(existsSync);
|
|
if (!force && existingTargets.length > 0) {
|
|
throw new Error(
|
|
`Refusing to overwrite existing release file${existingTargets.length === 1 ? '' : 's'}: ${existingTargets.join(', ')}. Pass --force to replace the same-version artifact.`
|
|
);
|
|
}
|
|
|
|
const writeOptions = force ? undefined : { flag: 'wx' };
|
|
writeFileSync(archiveTarget, archive, writeOptions);
|
|
writeFileSync(checksumTarget, checksum, {
|
|
encoding: 'utf8',
|
|
...(writeOptions ?? {}),
|
|
});
|
|
}
|
|
|
|
export function packageRelease({ force = false } = {}) {
|
|
const archive = createDeterministicZip(collectReleaseEntries());
|
|
const checksum = createHash('sha256').update(archive).digest('hex');
|
|
|
|
writeReleaseFiles({
|
|
archive,
|
|
archiveTarget: archivePath,
|
|
checksum: `${checksum} ${artifactName}.zip\n`,
|
|
checksumTarget: checksumPath,
|
|
force,
|
|
});
|
|
|
|
console.log(relative(repositoryRoot, archivePath));
|
|
console.log(relative(repositoryRoot, checksumPath));
|
|
}
|
|
|
|
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
|
|
const arguments_ = process.argv.slice(2);
|
|
const unknownArguments = arguments_.filter(
|
|
(argument) => argument !== '--force'
|
|
);
|
|
if (unknownArguments.length > 0) {
|
|
throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`);
|
|
}
|
|
packageRelease({ force: arguments_.includes('--force') });
|
|
}
|