Files
toolbox-portal/scripts/release-lock.mjs

138 lines
4.6 KiB
JavaScript

import { parseToolboxCatalog } from '@add-ideas/toolbox-contract';
const SHA256 = /^[a-f0-9]{64}$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const REVERSE_DNS_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
const TARGET_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function record(value, field) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new Error(`${field} must be an object.`);
return value;
}
function string(value, field) {
if (typeof value !== 'string' || value.trim() === '')
throw new Error(`${field} must be a non-empty string.`);
return value;
}
function onlyKeys(value, allowed, field) {
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
if (unexpected.length > 0)
throw new Error(`${field} contains unsupported field: ${unexpected[0]}.`);
}
function usesLatestAlias(value) {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// An invalid encoded URL will be rejected when the artifact is acquired.
}
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
}
export function parseReleaseLock(value) {
const lock = record(value, 'lock');
onlyKeys(
lock,
new Set([
'$schema',
'schemaVersion',
'releaseVersion',
'portalVersion',
'catalogue',
'apps',
]),
'lock'
);
if (lock.schemaVersion !== 1)
throw new Error('lock.schemaVersion must be 1.');
string(lock.releaseVersion, 'lock.releaseVersion');
if (!SEMVER.test(lock.releaseVersion))
throw new Error('lock.releaseVersion must use semantic versioning.');
string(lock.portalVersion, 'lock.portalVersion');
if (!SEMVER.test(lock.portalVersion))
throw new Error('lock.portalVersion must use semantic versioning.');
const catalogue = record(lock.catalogue, 'lock.catalogue');
onlyKeys(
catalogue,
new Set(['id', 'name', 'home', 'theme']),
'lock.catalogue'
);
string(catalogue.id, 'lock.catalogue.id');
if (!REVERSE_DNS_ID.test(catalogue.id))
throw new Error('lock.catalogue.id must be a reverse-DNS identifier.');
string(catalogue.name, 'lock.catalogue.name');
string(catalogue.home, 'lock.catalogue.home');
const theme = record(catalogue.theme, 'lock.catalogue.theme');
onlyKeys(theme, new Set(['mode', 'brand']), 'lock.catalogue.theme');
if (!['light', 'dark', 'system'].includes(theme.mode))
throw new Error('lock.catalogue.theme.mode is invalid.');
string(theme.brand, 'lock.catalogue.theme.brand');
if (!Array.isArray(lock.apps) || lock.apps.length === 0)
throw new Error('lock.apps must contain at least one pinned app.');
const ids = new Set();
const targets = new Set();
lock.apps.forEach((rawApp, index) => {
const app = record(rawApp, `lock.apps[${index}]`);
onlyKeys(
app,
new Set(['id', 'version', 'artifact', 'sha256', 'target']),
`lock.apps[${index}]`
);
string(app.id, `lock.apps[${index}].id`);
if (!REVERSE_DNS_ID.test(app.id))
throw new Error(
`lock.apps[${index}].id must be a reverse-DNS identifier.`
);
string(app.version, `lock.apps[${index}].version`);
if (!SEMVER.test(app.version))
throw new Error(
`lock.apps[${index}].version must use semantic versioning.`
);
string(app.artifact, `lock.apps[${index}].artifact`);
if (usesLatestAlias(app.artifact))
throw new Error(
`lock.apps[${index}].artifact must not use a latest alias.`
);
string(app.sha256, `lock.apps[${index}].sha256`);
if (!SHA256.test(app.sha256))
throw new Error(
`lock.apps[${index}].sha256 must be 64 lowercase hexadecimal characters.`
);
string(app.target, `lock.apps[${index}].target`);
if (!TARGET_SLUG.test(app.target))
throw new Error(`lock.apps[${index}].target must be a safe URL slug.`);
if (ids.has(app.id))
throw new Error(`Duplicate app id in lock: ${app.id}.`);
if (targets.has(app.target))
throw new Error(`Duplicate app target in lock: ${app.target}.`);
ids.add(app.id);
targets.add(app.target);
});
return lock;
}
export function buildCatalogue(lock) {
const catalogue = {
schemaVersion: 1,
id: lock.catalogue.id,
name: lock.catalogue.name,
home: lock.catalogue.home,
theme: lock.catalogue.theme,
apps: lock.apps.map((app) => ({
manifest: `./apps/${app.target}/toolbox-app.json`,
enabled: true,
})),
};
return {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
...parseToolboxCatalog(catalogue),
};
}