543 lines
19 KiB
JavaScript
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();
|
|
}
|