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' }, entries: { type: 'string', default: 'dist/suite.json' }, } }); const base = values['base-url'] ? new URL(values['base-url']) : null; if (base && (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 && !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.resolve(root, values.entries), '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 = base ? new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href : relative; 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 }); } // Git publication keeps immutable packages alongside the canonical index. if (!base) { for (const file of files) { try { const existing = await readFile(path.join(catalogDirectory, file.relative)); if (existing.length !== file.size || createHash('sha256').update(existing).digest('hex') !== file.sha256) { throw new Error(`Published artifact differs: ${file.relative}; bump the plugin version`); } } catch (error) { if (error.code !== 'ENOENT') throw error; } } } // 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); if (!base) { const published = path.join(catalogDirectory, file.relative); await mkdir(path.dirname(published), { recursive: true }); await copyFile(file.from, published); } } 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({ kind: base ? 'http' : 'git', baseUrl: base?.href, files }, null, 2) + '\n'); console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`); console.log(`Catalog history: ${catalogDirectory}`); console.log(base ? 'Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.' : 'Next: commit source/index.json and source/packages/ together, push the repository, then verify the Git source.');