#!/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 = `${archive}.sha256`; const releaseSha256 = await sha256ForPath(archive); await writeFile(releaseChecksum, `${releaseSha256} ${path.basename(archive)}\n`, 'utf8'); console.log(`Assembled release checksum: ${releaseSha256}`); } run().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });