Files
16gagent/scripts/release-builder-agent.mjs
T
2026-06-06 10:40:48 +08:00

543 lines
19 KiB
JavaScript

#!/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 <electron-dir> [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 <electron-dir> [options]\n\n" +
"Options:\n" +
" --input <dir> Project directory (with apps/web, apps/api, apps/desktop)\n" +
" --output <dir> Output directory (default: <input>/release)\n" +
" --name <name> Override project name\n" +
" --version <ver> Override version\n" +
" --build-number <n> 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();
}