feat: release toolbox portal 0.1.0
This commit is contained in:
381
scripts/assemble.mjs
Normal file
381
scripts/assemble.mjs
Normal file
@@ -0,0 +1,381 @@
|
||||
#!/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user