Files
toolbox-portal/scripts/update-release-lock.mjs
T
2026-09-03 09:49:09 +02:00

242 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { access, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { parseReleaseLock } from './release-lock.mjs';
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const HEX_HASH = /^[a-f0-9]{64}$/;
function usage() {
return `Usage: node scripts/update-release-lock.mjs --app-id ID --app-version VERSION --artifact URL --sha256 HEX --release-version SEMVER [options]
Options:
--lock PATH Path to release lock (default: release/toolbox.lock.json)
--portal-version SEMVER Optional portal package override
--app-id ID App id in lock (for example de.add-ideas.translator-tools)
--app-version VERSION App semantic version
--artifact URL Artifact URL/path/file: URL
--sha256 HEX Optional artifact checksum; required for remote artifacts
--release-version VERSION New toolbox release version
--assemble Run npm run build and npm run assemble after patching
--force Pass --force to assemble command
--help Show this message
`;
}
function parseArguments(argv) {
if (argv.includes('--help')) {
console.log(usage());
process.exit(0);
}
const args = new Map();
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index];
if (!option.startsWith('--')) {
throw new Error(`Unknown argument: ${option}`);
}
if (option === '--assemble' || option === '--force') {
args.set(option, true);
continue;
}
const value = argv[index + 1];
if (value === undefined) {
throw new Error(`${option} requires a value.`);
}
args.set(option, value);
index += 1;
}
return {
lockFile: args.get('--lock') ?? 'release/toolbox.lock.json',
appId: args.get('--app-id'),
appVersion: args.get('--app-version'),
artifact: args.get('--artifact'),
sha256: args.get('--sha256'),
releaseVersion: args.get('--release-version'),
portalVersion: args.get('--portal-version'),
assemble: args.get('--assemble') === true,
forceAssemble: args.get('--force') === true,
};
}
function bumpPatch(version) {
const [major, minor, patchAndSuffix] = version.split('.');
const [patch] = patchAndSuffix.split('-');
const nextPatch = Number.parseInt(patch, 10) + 1;
return `${major}.${minor}.${nextPatch}`;
}
async function sha256ForPath(filePath) {
await access(filePath);
const hash = createHash('sha256');
const input = createReadStream(filePath);
await new Promise((resolve, reject) => {
input.on('data', (chunk) => hash.update(chunk));
input.on('error', reject);
input.on('end', resolve);
});
return hash.digest('hex');
}
async function computeRemoteSha256(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Unable to download artifact (${response.status} ${response.statusText}).`);
}
if (!response.body) {
throw new Error('Artifact download did not provide a response body.');
}
const hash = createHash('sha256');
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
hash.update(Buffer.from(value));
}
return hash.digest('hex');
}
function resolveArtifactPath(artifact, lockDirectory) {
if (/^file:/i.test(artifact)) return new URL(artifact).pathname;
if (/^https?:/i.test(artifact)) return null;
return path.resolve(lockDirectory, artifact);
}
function toShaIfNeeded(hash) {
if (!hash) return null;
const normalized = hash.toLowerCase();
if (!HEX_HASH.test(normalized)) {
throw new Error('Provided --sha256 is not a valid 64 hex hash.');
}
return normalized;
}
function command(cwd) {
return (name, args) =>
new Promise((resolve, reject) => {
const child = spawn(name, args, {
cwd,
stdio: 'inherit',
shell: true,
});
child.on('error', reject);
child.on('exit', (code) =>
code === 0
? resolve(undefined)
: reject(new Error(`${name} ${args.join(' ')} failed with code ${code}`))
);
});
}
async function run() {
const options = parseArguments(process.argv.slice(2));
if (
!options.appId ||
!options.appVersion ||
!options.artifact ||
!options.releaseVersion
) {
throw new Error(`Missing required options.
${usage()}`);
}
if (!SEMVER.test(options.releaseVersion)) {
throw new Error(`--release-version must be semver: ${options.releaseVersion}`);
}
if (!SEMVER.test(options.appVersion)) {
throw new Error(`--app-version must be semver: ${options.appVersion}`);
}
const lockPath = path.resolve(options.lockFile);
const lockDir = path.dirname(lockPath);
const lockJson = JSON.parse(await readFile(lockPath, 'utf8'));
const lock = parseReleaseLock(lockJson);
const nextReleaseVersion = options.releaseVersion ?? bumpPatch(lock.releaseVersion);
if (!SEMVER.test(nextReleaseVersion)) {
throw new Error(`Invalid release version: ${nextReleaseVersion}`);
}
const appIndex = lock.apps.findIndex((app) => app.id === options.appId);
if (appIndex < 0) {
throw new Error(`App id not found in lock: ${options.appId}`);
}
const artifactPath = resolveArtifactPath(options.artifact, lockDir);
let sha256 = toShaIfNeeded(options.sha256);
if (!sha256) {
if (artifactPath) {
sha256 = await sha256ForPath(artifactPath);
} else {
sha256 = await computeRemoteSha256(options.artifact);
}
}
if (!HEX_HASH.test(sha256)) {
throw new Error('Computed checksum is malformed.');
}
const updatedApps = [...lock.apps];
const previous = updatedApps[appIndex];
updatedApps[appIndex] = {
...previous,
version: options.appVersion,
artifact: options.artifact,
sha256,
};
const nextLock = {
...lock,
releaseVersion: nextReleaseVersion,
apps: updatedApps,
};
if (options.portalVersion) {
if (!SEMVER.test(options.portalVersion))
throw new Error(`--portal-version must be semver: ${options.portalVersion}`);
nextLock.portalVersion = options.portalVersion;
}
parseReleaseLock(nextLock);
await writeFile(lockPath, `${JSON.stringify(nextLock, null, 2)}\n`, 'utf8');
console.log(`Updated ${path.relative(process.cwd(), lockPath)} (${options.appId})`);
if (!options.assemble) return;
const runCommand = command(process.cwd());
await runCommand('npm', ['run', 'build']);
const archive = `build/add-ideas-toolbox-${nextReleaseVersion}.zip`;
const assembleArgs = [
'run',
'assemble',
'--',
'--lock',
options.lockFile,
'--portal-dist',
'dist',
'--output',
'build/toolbox',
'--archive',
archive,
];
if (options.forceAssemble) assembleArgs.push('--force');
await runCommand('npm', assembleArgs);
console.log(`Assembled release -> ${archive}`);
const releaseChecksum = path.join(
path.dirname(archive),
`${path.basename(archive)}.sha256`
);
const releaseSha256 = await sha256ForPath(releaseChecksum === undefined ? '' : releaseChecksum);
console.log(`Release checksum: ${releaseSha256}`);
}
run().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});