382 lines
13 KiB
JavaScript
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;
|
|
});
|
|
}
|