#!/usr/bin/env node /** * Release Builder Agent — SF-07 * * 将 SF-06 Electron Project 封装为可发布软件包结构。 * * 职责:Electron Project → Release Package Structure * 不做:自动更新、CI/CD、Docker、云发布、安装器、签名、证书 * * Usage: * node scripts/release-builder-agent.mjs --input [options] * * @module release-builder-agent */ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import { resolve, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; const __dirname = dirname(resolve(fileURLToPath(import.meta.url))); const WORKSPACE = resolve(__dirname, ".."); const GENERATOR_VERSION = "1.0.0"; const PLATFORMS = ["windows", "macos", "linux"]; // ═══════════════════════════════════════════════════════ // 1. Metadata Extraction // ═══════════════════════════════════════════════════════ function extractMetadataSync(projectDir) { const meta = { projectName: "app", version: "1.0.0", buildNumber: 1, domain: "unknown", entities: [], routes: [], screens: [], }; // Read root package.json const rootPkgPath = join(projectDir, "package.json"); if (existsSync(rootPkgPath)) { try { const pkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); meta.projectName = pkg.name || meta.projectName; meta.version = pkg.version || meta.version; } catch {} } // Read desktop package.json const desktopPkgPath = join(projectDir, "apps", "desktop", "package.json"); if (existsSync(desktopPkgPath)) { try { const pkg = JSON.parse(readFileSync(desktopPkgPath, "utf8")); if (pkg.name) meta.projectName = pkg.name.replace(/-desktop$/, ""); if (pkg.version) meta.version = pkg.version; } catch {} } // Read PRD if exists const prdPath = join(projectDir, "prd.json"); if (existsSync(prdPath)) { try { const prd = JSON.parse(readFileSync(prdPath, "utf8")); if (prd.projectName) meta.projectName = prd.projectName; if (prd.domain) meta.domain = prd.domain; if (prd.features) meta.entities = prd.features.map(f => f.name || f); if (prd.apiRequirements) meta.routes = prd.apiRequirements.map(r => `${r.method} ${r.path}`); if (prd.pages) meta.screens = prd.pages.map(p => p.name || p); } catch {} } // Scan for entities in backend routes const routesDir = join(projectDir, "apps", "api", "src", "routes"); if (existsSync(routesDir)) { try { const files = readdirSync(routesDir).filter(f => f.endsWith(".ts") || f.endsWith(".js")); if (meta.entities.length === 0) { meta.entities = files.map(f => f.replace(/\.(ts|js)$/, "").replace(/-route$/, "").replace(/\.route$/, "")); } } catch {} } // Scan for screens in frontend const appDir = join(projectDir, "apps", "web", "app"); if (existsSync(appDir)) { try { const entries = readdirSync(appDir, { withFileTypes: true }); if (meta.screens.length === 0) { meta.screens = entries .filter(e => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")) .map(e => e.name); } } catch {} } // Count API routes from index.ts const apiSrcDir = join(projectDir, "apps", "api", "src"); if (existsSync(apiSrcDir) && meta.routes.length === 0) { try { const indexContent = readFileSync(join(apiSrcDir, "index.ts"), "utf8"); const routeMatches = indexContent.match(/app\.(use|get|post|put|patch|delete)\s*\(\s*["']([^"']+)/g); if (routeMatches) meta.routes = routeMatches; } catch {} } return meta; } // ═══════════════════════════════════════════════════════ // 2. Version Generator // ═══════════════════════════════════════════════════════ function generateVersion(meta) { return JSON.stringify({ name: meta.projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), version: meta.version, buildNumber: meta.buildNumber, platforms: [...PLATFORMS], }, null, 2); } // ═══════════════════════════════════════════════════════ // 3. Release Manifest // ═══════════════════════════════════════════════════════ function generateManifest(meta, artifacts) { const files = []; for (const art of artifacts) { files.push({ path: art.path, size: art.size, sha256: art.sha256, }); } return JSON.stringify({ project: meta.projectName, version: meta.version, buildNumber: meta.buildNumber, generatedAt: new Date().toISOString(), generator: "release-builder-agent", generatorVersion: GENERATOR_VERSION, platforms: [...PLATFORMS], files, totalFiles: files.length, totalSize: files.reduce((s, f) => s + f.size, 0), }, null, 2); } // ═══════════════════════════════════════════════════════ // 4. Checksum Generator // ═══════════════════════════════════════════════════════ function sha256(content) { return createHash("sha256").update(typeof content === "string" ? content : content).digest("hex"); } function generateChecksums(artifacts) { return artifacts.map(a => `${a.sha256} ${a.path}`).join("\n") + "\n"; } // ═══════════════════════════════════════════════════════ // 5. Release Notes // ═══════════════════════════════════════════════════════ function generateReleaseNotes(meta) { const now = new Date(); const dateStr = now.toISOString().split("T")[0]; const timeStr = now.toISOString().split("T")[1].split(".")[0]; const lines = []; const h = (t) => { lines.push(t); return lines; }; h(`# ${meta.projectName} v${meta.version}`); h(``); h(`> Release Builder Agent — SF-07`); h(`> Generated: ${dateStr} ${timeStr} UTC`); h(``); h(`## Version`); h(``); h(`- **Name:** ${meta.projectName}`); h(`- **Version:** ${meta.version}`); h(`- **Build:** #${meta.buildNumber}`); h(``); h(`## Features`); h(``); if (meta.entities.length > 0) { for (const e of meta.entities) { h(`- ${e}`); } } else { h(`- (No features detected)`); } h(``); h(`## Build Info`); h(``); h(`- **Build Time:** ${now.toISOString()}`); h(`- **Generator:** release-builder-agent v${GENERATOR_VERSION}`); h(`- **Node Version:** ${process.version}`); h(``); h(`## Platforms`); h(``); for (const p of PLATFORMS) { h(`- ✅ ${p}`); } h(``); h(`## Installation`); h(``); h(`### Windows`); h(`\`\`\``); h(`Download the .exe installer from release/windows/`); h(`\`\`\``); h(``); h(`### macOS`); h(`\`\`\``); h(`Download the .dmg from release/macos/`); h(`\`\`\``); h(``); h(`### Linux`); h(`\`\`\``); h(`Download the .AppImage from release/linux/`); h(`\`\`\``); h(``); h(`## Checksums`); h(``); h(`See \`checksums/checksums.txt\` for SHA256 verification.`); h(``); h(`---`); h(`*Generated by Release Builder Agent — SF-07*`); return lines.join("\n"); } // ═══════════════════════════════════════════════════════ // 6. Build Metadata // ═══════════════════════════════════════════════════════ function generateBuildInfo(meta) { return JSON.stringify({ nodeVersion: process.version, generatorVersion: GENERATOR_VERSION, buildTime: new Date().toISOString(), domain: meta.domain, entityCount: meta.entities.length, routeCount: meta.routes.length, screenCount: meta.screens.length, platforms: [...PLATFORMS], project: meta.projectName, version: meta.version, }, null, 2); } // ═══════════════════════════════════════════════════════ // 7. Platform Artifact Layout // ═══════════════════════════════════════════════════════ function generatePlatformArtifacts(meta) { const safeName = meta.projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-"); const artifacts = {}; // Windows artifacts[`windows/${safeName}-setup-${meta.version}.exe`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} Windows Installer\n` + `This is a placeholder file for the Windows NSIS installer.\n` + `Generated: ${new Date().toISOString()}\n`; artifacts[`windows/${safeName}-${meta.version}-portable.exe`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} Windows Portable\n` + `This is a placeholder file for the Windows portable executable.\n` + `Generated: ${new Date().toISOString()}\n`; // macOS artifacts[`macos/${safeName}-${meta.version}.dmg`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} macOS Disk Image\n` + `This is a placeholder file for the macOS DMG installer.\n` + `Generated: ${new Date().toISOString()}\n`; artifacts[`macos/${safeName}-${meta.version}-arm64.dmg`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} macOS ARM64 Disk Image\n` + `This is a placeholder file for the macOS ARM64 DMG.\n` + `Generated: ${new Date().toISOString()}\n`; // Linux artifacts[`linux/${safeName}-${meta.version}.AppImage`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} Linux AppImage\n` + `This is a placeholder file for the Linux AppImage.\n` + `Generated: ${new Date().toISOString()}\n`; artifacts[`linux/${safeName}_${meta.version}_amd64.deb`] = `[PLACEHOLDER] ${meta.projectName} v${meta.version} Linux Debian Package\n` + `This is a placeholder file for the Linux .deb package.\n` + `Generated: ${new Date().toISOString()}\n`; return artifacts; } // ═══════════════════════════════════════════════════════ // 8. Main Build Function // ═══════════════════════════════════════════════════════ /** * Build release package structure for an Electron project. * * @param {object} options * @param {string} options.projectDir — Electron project root (with apps/web, apps/api, apps/desktop) * @param {string} [options.projectName] — Override project name * @param {string} [options.version] — Override version * @param {number} [options.buildNumber] — Override build number * @returns {object} { files, stats, error, message } */ export function buildRelease(options = {}) { const { projectDir = null, projectName = null, version = null, buildNumber = 1, } = options; // Extract metadata from project const meta = projectDir ? extractMetadataSync(projectDir) : { projectName: "app", version: "1.0.0", buildNumber: 1, domain: "unknown", entities: [], routes: [], screens: [], }; // Apply overrides if (projectName) meta.projectName = projectName; if (version) meta.version = version; meta.buildNumber = buildNumber; const files = {}; // Generate platform artifacts (placeholders) const platformArtifacts = generatePlatformArtifacts(meta); // Add platform artifacts to files for (const [path, content] of Object.entries(platformArtifacts)) { files[`release/${path}`] = content; } // Compute artifact metadata for manifest const artifactMeta = Object.entries(platformArtifacts).map(([path, content]) => ({ path, size: Buffer.byteLength(content, "utf8"), sha256: sha256(content), })); // Generate version.json files["release/version.json"] = generateVersion(meta); // Generate manifest.json files["release/manifests/manifest.json"] = generateManifest(meta, artifactMeta); // Generate checksums.txt files["release/checksums/checksums.txt"] = generateChecksums(artifactMeta); // Generate release-notes.md files["release/release-notes/release-notes.md"] = generateReleaseNotes(meta); // Generate build-info.json files["release/build-info.json"] = generateBuildInfo(meta); // Generate README files["release/README.md"] = generateReadme(meta); // Stats const allPaths = Object.keys(files); const stats = { totalFiles: allPaths.length, platformFiles: allPaths.filter(p => p.startsWith("release/windows/") || p.startsWith("release/macos/") || p.startsWith("release/linux/")).length, manifestFiles: allPaths.filter(p => p.includes("manifest")).length, checksumFiles: allPaths.filter(p => p.includes("checksum")).length, releaseNotesFiles: allPaths.filter(p => p.includes("release-notes")).length, metadataFiles: allPaths.filter(p => p.endsWith(".json")).length, totalSize: Object.values(files).reduce((s, c) => s + Buffer.byteLength(c, "utf8"), 0), }; return { files, stats, meta, error: null, message: null }; } // ═══════════════════════════════════════════════════════ // 9. README Generator // ═══════════════════════════════════════════════════════ function generateReadme(meta) { const lines = []; const h = (t) => { lines.push(t); return lines; }; h(`# ${meta.projectName} — Release Package`); h(``); h(`> Version: ${meta.version} | Build: #${meta.buildNumber}`); h(`> Generated: ${new Date().toISOString()}`); h(``); h(`## Directory Structure`); h(``); h(`\`\`\``); h(`release/`); h(`├── version.json — Version metadata`); h(`├── build-info.json — Build environment info`); h(`├── README.md — This file`); h(`├── manifests/`); h(`│ └── manifest.json — Release manifest with file hashes`); h(`├── checksums/`); h(`│ └── checksums.txt — SHA256 checksums for verification`); h(`├── release-notes/`); h(`│ └── release-notes.md — Human-readable release notes`); h(`├── windows/ — Windows installers`); h(`├── macos/ — macOS disk images`); h(`└── linux/ — Linux packages`); h(`\`\`\``); h(``); h(`## Verification`); h(``); h(`\`\`\`bash`); h(`# Verify checksums`); h(`cd release/checksums`); h(`shasum -a 256 -c checksums.txt`); h(`\`\`\``); h(``); h(`---`); h(`*Generated by Release Builder Agent — SF-07*`); return lines.join("\n"); } // ═══════════════════════════════════════════════════════ // 10. File I/O // ═══════════════════════════════════════════════════════ export function writeRelease(result, outputDir) { mkdirSync(outputDir, { recursive: true }); for (const [relPath, content] of Object.entries(result.files)) { const fullPath = resolve(outputDir, relPath); mkdirSync(dirname(fullPath), { recursive: true }); writeFileSync(fullPath, content, "utf-8"); } } // ═══════════════════════════════════════════════════════ // 11. CLI Entry // ═══════════════════════════════════════════════════════ function parseArgs() { const args = process.argv.slice(2); const opts = { input: null, output: null, projectName: null, version: null, buildNumber: 1, verbose: false, help: false, }; for (let i = 0; i < args.length; i++) { if (args[i] === "--input" && args[i + 1]) opts.input = args[++i]; else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i]; else if (args[i] === "--name" && args[i + 1]) opts.projectName = args[++i]; else if (args[i] === "--version" && args[i + 1]) opts.version = args[++i]; else if (args[i] === "--build-number" && args[i + 1]) opts.buildNumber = parseInt(args[++i], 10); else if (args[i] === "--verbose") opts.verbose = true; else if (args[i] === "--help" || args[i] === "-h") opts.help = true; } return opts; } async function main() { const opts = parseArgs(); if (opts.help) { console.log( "\nRelease Builder Agent — SF-07\n\n" + "Generate release package structure for an Electron project.\n\n" + "Usage:\n" + " node scripts/release-builder-agent.mjs --input [options]\n\n" + "Options:\n" + " --input Project directory (with apps/web, apps/api, apps/desktop)\n" + " --output Output directory (default: /release)\n" + " --name Override project name\n" + " --version Override version\n" + " --build-number Build number (default: 1)\n" + " --verbose Verbose output\n" + " --help Show this help\n" ); return; } const result = buildRelease({ projectDir: opts.input, projectName: opts.projectName, version: opts.version, buildNumber: opts.buildNumber, }); if (result.error) { console.error(result.message); process.exit(1); } const outputDir = opts.output ? resolve(opts.output) : opts.input ? resolve(opts.input, "release") : resolve(WORKSPACE, "release"); writeRelease(result, outputDir); if (opts.verbose) { console.error("Project: " + result.meta.projectName); console.error("Version: " + result.meta.version); console.error("Files: " + result.stats.totalFiles); console.error("Platform files: " + result.stats.platformFiles); console.error("Total size: " + result.stats.totalSize + " bytes"); } console.log(JSON.stringify({ projectName: result.meta.projectName, version: result.meta.version, outputDir, stats: result.stats, }, null, 2)); } if (process.argv[1] && (import.meta.url === "file://" + process.argv[1] || import.meta.url.endsWith(process.argv[1]))) { main(); }