import { readFile, writeFile, mkdir, copyFile, rename, rm } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { parseArgs } from 'node:util'; import path from 'node:path'; import { root, output } from './suite.mjs'; import { appendRelease, artifactPath } from './release-catalog.mjs'; const { values } = parseArgs({ options: { 'base-url': { type: 'string' }, catalog: { type: 'string', default: 'source' }, } }); if (!values['base-url']) throw new Error('Usage: pnpm run release:prepare --base-url https://downloads.example/source/'); const base = new URL(values['base-url']); if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { throw new Error('Publication base must be an HTTPS directory URL without credentials, query or fragment'); } if (!base.pathname.endsWith('/')) base.pathname += '/'; const catalogDirectory = path.resolve(root, values.catalog); const publishDirectory = path.join(output, 'release'); const timestamp = new Date().toISOString(); const specifications = [ { file: 'index.json', id: 'trove-official', name: 'Trove 官方软件源' }, ]; const catalogs = new Map(); for (const spec of specifications) { let index; try { index = JSON.parse(await readFile(path.join(catalogDirectory, spec.file), 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; index = { schemaVersion: 1, id: spec.id, name: spec.name, generatedAt: timestamp, curios: [] }; } if (index.id !== spec.id || index.schemaVersion !== 1 || !Array.isArray(index.curios)) throw new Error(`Unexpected catalog identity: ${spec.file}`); catalogs.set(spec.file, index); } const entries = JSON.parse(await readFile(path.join(output, 'suite.json'), 'utf8')); const files = []; // Validate every proposed addition before changing any catalog or copying artifacts. for (const entry of entries) { if (entry.fault) continue; const bytes = await readFile(entry.file); const sha256 = createHash('sha256').update(bytes).digest('hex'); if (sha256 !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error(`Build metadata doesn't match ${entry.file}; run pack again`); const relative = artifactPath(entry.manifest, entry.artifact.target); const url = new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href; const spec = specifications[0]; const artifact = { ...entry.artifact, url }; catalogs.set(spec.file, appendRelease(catalogs.get(spec.file), entry.manifest, artifact, timestamp)); files.push({ from: entry.file, relative, sha256, size: bytes.length, url }); } // Only the current approved publication plan belongs in the generated upload directory. await rm(publishDirectory, { recursive: true, force: true }); for (const file of files) { const destination = path.join(publishDirectory, file.relative); await mkdir(path.dirname(destination), { recursive: true }); await copyFile(file.from, destination); } await mkdir(catalogDirectory, { recursive: true }); await mkdir(publishDirectory, { recursive: true }); for (const [file, index] of catalogs) { const text = JSON.stringify(index, null, 2) + '\n'; await writeFile(path.join(publishDirectory, file), text); const temporary = path.join(catalogDirectory, `${file}.tmp`); await writeFile(temporary, text); await rename(temporary, path.join(catalogDirectory, file)); } await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ baseUrl: base.href, files }, null, 2) + '\n'); console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`); console.log(`Catalog history: ${catalogDirectory}`); console.log('Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.');