#!/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; }); }