Files
toolbox-portal/scripts/assemble.mjs

382 lines
13 KiB
JavaScript

#!/usr/bin/env node
import { constants } from 'node:fs';
import {
access,
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
import { acquireArtifact, sha256File } from './artifact.mjs';
import { createStaticArchive } from './archive.mjs';
import {
assertSafeRemovalTarget,
canonicalExistingPath,
canonicalPathsOverlap,
} from './path-safety.mjs';
import { publishStagedTargets } from './publication.mjs';
import { buildCatalogue, parseReleaseLock } from './release-lock.mjs';
import {
extractInspectedArchive,
inspectAppArchive,
safeEntryPath,
} from './zip-security.mjs';
function exists(file) {
return access(file).then(
() => true,
() => false
);
}
async function readJson(file, label) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
throw new Error(`${label} is not valid JSON: ${file}`, { cause: error });
}
}
async function copyDirectoryContents(source, destination) {
for (const entry of await readdir(source, { withFileTypes: true })) {
if (entry.isSymbolicLink())
throw new Error(`Portal dist contains a symbolic link: ${entry.name}`);
if (!entry.isDirectory() && !entry.isFile())
throw new Error(
`Portal dist contains an unsupported file: ${entry.name}`
);
const sourcePath = path.join(source, entry.name);
const destinationPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await mkdir(destinationPath);
await copyDirectoryContents(sourcePath, destinationPath);
} else {
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL);
}
}
}
function manifestLocalFile(reference, field, directoryIndex = false) {
if (
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
reference.startsWith('/') ||
reference.startsWith('//') ||
reference.includes('?') ||
reference.includes('#')
) {
throw new Error(
`${field} must be a relative packaged path without query parameters or fragments.`
);
}
const normalized = safeEntryPath(reference);
if (normalized.endsWith('/') || normalized === '.') {
if (directoryIndex) return path.posix.join(normalized, 'index.html');
throw new Error(`${field} must identify a packaged file.`);
}
return normalized;
}
async function requireManifestFile(root, reference, appId, field, options) {
const relative = manifestLocalFile(reference, field, options?.directoryIndex);
const canonicalRoot = path.resolve(root);
const file = path.resolve(canonicalRoot, ...relative.split('/'));
if (!file.startsWith(`${canonicalRoot}${path.sep}`))
throw new Error(`${field} escapes its target: ${appId}`);
let details;
try {
details = await stat(file);
} catch (error) {
throw new Error(`${field} is missing for ${appId}: ${reference}`, {
cause: error,
});
}
if (!details.isFile())
throw new Error(`${field} is not a file for ${appId}: ${reference}`);
}
async function smokeCheck(output, lock, manifests) {
const index = path.join(output, 'index.html');
if (!(await exists(index)))
throw new Error('Portal dist does not contain index.html.');
const catalogue = await readJson(
path.join(output, 'toolbox.catalog.json'),
'Generated catalogue'
);
if (catalogue.apps.length !== lock.apps.length)
throw new Error('Generated catalogue app count does not match the lock.');
for (let index = 0; index < lock.apps.length; index += 1) {
const app = lock.apps[index];
const manifest = manifests[index];
const root = path.join(output, 'apps', app.target);
await requireManifestFile(root, manifest.entry, app.id, 'manifest.entry', {
directoryIndex: true,
});
await requireManifestFile(root, manifest.icon, app.id, 'manifest.icon');
for (const [assetIndex, asset] of (manifest.assets ?? []).entries()) {
await requireManifestFile(
root,
asset,
app.id,
`manifest.assets[${assetIndex}]`
);
}
const copiedManifest = parseToolboxApp(
await readJson(path.join(root, 'toolbox-app.json'), 'Copied app manifest')
);
if (copiedManifest.id !== app.id || copiedManifest.version !== app.version)
throw new Error(`Copied manifest identity changed for ${app.id}.`);
}
}
function provenance(lock) {
return {
schemaVersion: 1,
releaseVersion: lock.releaseVersion,
portalVersion: lock.portalVersion,
apps: lock.apps.map(({ id, version, sha256, target }) => ({
id,
version,
sha256,
target,
})),
};
}
export async function assembleRelease({
lockFile,
portalDist,
outputDirectory,
archiveFile,
force = false,
}) {
const lockPath = await canonicalExistingPath(lockFile, 'Release lock');
const portalPath = await canonicalExistingPath(portalDist, 'Portal dist');
const outputPath = await assertSafeRemovalTarget(outputDirectory);
const archivePath = archiveFile
? await assertSafeRemovalTarget(archiveFile)
: undefined;
const checksumPath = archivePath
? await assertSafeRemovalTarget(`${archivePath}.sha256`)
: undefined;
if (archivePath && path.extname(archivePath).toLocaleLowerCase() !== '.zip')
throw new Error('Archive output must use a .zip extension.');
if (canonicalPathsOverlap(outputPath, portalPath))
throw new Error('Output and portal dist directories must not overlap.');
if (canonicalPathsOverlap(outputPath, lockPath))
throw new Error('Output must not contain or replace the release lock.');
if (archivePath && canonicalPathsOverlap(archivePath, outputPath))
throw new Error('Archive must be outside the assembled output directory.');
if (checksumPath && canonicalPathsOverlap(checksumPath, outputPath))
throw new Error('Checksum must be outside the assembled output directory.');
if (archivePath && canonicalPathsOverlap(archivePath, portalPath))
throw new Error('Archive must be outside the portal dist directory.');
if (checksumPath && canonicalPathsOverlap(checksumPath, portalPath))
throw new Error('Checksum must be outside the portal dist directory.');
if (archivePath && canonicalPathsOverlap(archivePath, lockPath))
throw new Error('Archive must not replace the release lock.');
if (checksumPath && canonicalPathsOverlap(checksumPath, lockPath))
throw new Error('Checksum must not replace the release lock.');
const lock = parseReleaseLock(await readJson(lockPath, 'Release lock'));
const generatedCatalogue = buildCatalogue(lock);
const portalPackage = await readJson(
fileURLToPath(new URL('../package.json', import.meta.url)),
'Portal package'
);
if (portalPackage.version !== lock.portalVersion) {
throw new Error(
`Portal version mismatch: lock pins ${lock.portalVersion}, package is ${portalPackage.version}.`
);
}
const portalDetails = await stat(portalPath);
if (!portalDetails.isDirectory())
throw new Error('Portal dist must be a regular directory.');
if (!(await exists(path.join(portalPath, 'index.html'))))
throw new Error(
'Portal dist does not contain index.html; run npm run build first.'
);
const outputExists = await exists(outputPath);
const archiveExists = archivePath ? await exists(archivePath) : false;
const checksumExists = checksumPath ? await exists(checksumPath) : false;
if (outputExists && !force)
throw new Error(
`Output already exists (use --force to replace it): ${outputPath}`
);
if (archivePath && archiveExists && !force)
throw new Error(
`Archive already exists (use --force to replace it): ${archivePath}`
);
if (checksumPath && checksumExists && !force)
throw new Error(
`Checksum file already exists (use --force to replace it): ${checksumPath}`
);
await mkdir(path.dirname(outputPath), { recursive: true });
const staging = await mkdtemp(
path.join(path.dirname(outputPath), '.toolbox-assemble-')
);
const downloads = path.join(staging, '.downloads');
const manifests = [];
let archiveStaging;
try {
await copyDirectoryContents(portalPath, staging);
await mkdir(path.join(staging, 'apps'), { recursive: true });
for (const app of lock.apps) {
const artifact = await acquireArtifact(
app.artifact,
app.sha256,
path.dirname(lockPath),
downloads
);
const inspection = await inspectAppArchive(artifact.file);
const manifest = parseToolboxApp(inspection.manifest);
if (manifest.id !== app.id)
throw new Error(
`Manifest id mismatch for ${app.target}: expected ${app.id}, received ${manifest.id}.`
);
if (manifest.version !== app.version)
throw new Error(
`Manifest version mismatch for ${app.id}: expected ${app.version}, received ${manifest.version}.`
);
await extractInspectedArchive(
artifact.file,
path.join(staging, 'apps', app.target),
inspection
);
manifests.push(manifest);
}
await rm(downloads, { recursive: true, force: true });
await writeFile(
path.join(staging, 'toolbox.catalog.json'),
`${JSON.stringify(generatedCatalogue, null, 2)}\n`,
{ flag: 'w' }
);
await writeFile(
path.join(staging, 'toolbox.release.json'),
`${JSON.stringify(provenance(lock), null, 2)}\n`,
{ flag: 'wx' }
);
await smokeCheck(staging, lock, manifests);
if (archivePath) {
await mkdir(path.dirname(archivePath), { recursive: true });
archiveStaging = await mkdtemp(
path.join(path.dirname(archivePath), '.toolbox-archive-')
);
const stagedArchive = path.join(
archiveStaging,
path.basename(archivePath)
);
const stagedChecksum = `${stagedArchive}.sha256`;
await createStaticArchive(staging, stagedArchive);
const checksum = await sha256File(stagedArchive);
await writeFile(
stagedChecksum,
`${checksum} ${path.basename(archivePath)}\n`,
{ flag: 'wx' }
);
}
const publicationTargets = [{ staged: staging, target: outputPath }];
if (archivePath) {
publicationTargets.push(
{
staged: path.join(archiveStaging, path.basename(archivePath)),
target: archivePath,
},
{
staged: path.join(
archiveStaging,
`${path.basename(archivePath)}.sha256`
),
target: checksumPath,
}
);
}
await publishStagedTargets(publicationTargets, { force });
if (archivePath) {
await rm(archiveStaging, { recursive: true, force: true });
archiveStaging = undefined;
}
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (archiveStaging)
await rm(archiveStaging, { recursive: true, force: true });
throw error;
}
return {
outputDirectory: outputPath,
archiveFile: archivePath,
checksumFile: checksumPath,
releaseVersion: lock.releaseVersion,
};
}
function usage() {
return `Usage: node scripts/assemble.mjs --lock FILE [options]\n\nOptions:\n --portal-dist DIR Built portal directory (default: dist)\n --output DIR Assembled static directory (default: build/toolbox)\n --archive FILE Static ZIP (default: build/add-ideas-toolbox-VERSION.zip)\n --force Replace the exact output paths if they exist\n --help Show this message\n`;
}
function parseArguments(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--force' || argument === '--help')
values[argument.slice(2)] = true;
else if (
['--lock', '--portal-dist', '--output', '--archive'].includes(argument)
) {
const value = argv[index + 1];
if (!value || value.startsWith('--'))
throw new Error(`${argument} requires a value.`);
values[argument.slice(2)] = value;
index += 1;
} else throw new Error(`Unknown argument: ${argument}`);
}
return values;
}
async function main() {
const arguments_ = parseArguments(process.argv.slice(2));
if (arguments_.help) {
process.stdout.write(usage());
return;
}
if (!arguments_.lock) throw new Error(`--lock is required.\n\n${usage()}`);
const rawLock = parseReleaseLock(
await readJson(path.resolve(arguments_.lock), 'Release lock')
);
const result = await assembleRelease({
lockFile: arguments_.lock,
portalDist: arguments_['portal-dist'] ?? 'dist',
outputDirectory: arguments_.output ?? 'build/toolbox',
archiveFile:
arguments_.archive ??
`build/add-ideas-toolbox-${rawLock.releaseVersion}.zip`,
force: Boolean(arguments_.force),
});
process.stdout.write(
`Assembled toolbox ${result.releaseVersion} at ${result.outputDirectory}\nArchive: ${result.archiveFile}\nChecksum: ${result.checksumFile}\n`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main().catch((error) => {
process.stderr.write(
`Assembly failed: ${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
});
}