1201 lines
46 KiB
JavaScript
1201 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Electron Builder Agent — SF-06
|
||
*
|
||
* 将 SF-05 Fullstack Composer 输出的 Web 全栈项目封装为 Electron 桌面应用。
|
||
*
|
||
* 职责单一:Web Fullstack Project × Desktop Shell = 桌面软件
|
||
* 不重新实现:页面生成、API 生成、数据库生成、Auth 生成、CRUD 生成
|
||
*
|
||
* Usage:
|
||
* node scripts/electron-builder-agent.mjs --input <fullstack-dir> [options]
|
||
*
|
||
* @module electron-builder-agent
|
||
*/
|
||
|
||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||
import { resolve, dirname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const __dirname = dirname(resolve(fileURLToPath(import.meta.url)));
|
||
const WORKSPACE = resolve(__dirname, "..");
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 1. Package.json Generator
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generatePackageJson(projectName, webPort, apiPort) {
|
||
const safeName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||
const displayName = projectName || "Desktop App";
|
||
|
||
return JSON.stringify({
|
||
name: safeName + "-desktop",
|
||
version: "1.0.0",
|
||
private: true,
|
||
description: displayName + " — Desktop Application powered by Electron",
|
||
main: "dist/electron/main.js",
|
||
scripts: {
|
||
dev: 'concurrently "npm run dev:web" "npm run dev:api" "npm run dev:electron"',
|
||
"dev:electron": "tsc && electron .",
|
||
"dev:web": "cd ../web && npm run dev",
|
||
"dev:api": "cd ../api && npm run dev",
|
||
build: "npm run build:web && npm run build:api && npm run build:electron",
|
||
"build:web": "cd ../web && npm run build",
|
||
"build:api": "cd ../api && npm run build",
|
||
"build:electron": "tsc && electron-builder --config electron-builder.yml",
|
||
"build:electron:win": "tsc && electron-builder --win --config electron-builder.yml",
|
||
"build:electron:mac": "tsc && electron-builder --mac --config electron-builder.yml",
|
||
"build:electron:linux": "tsc && electron-builder --linux --config electron-builder.yml",
|
||
pack: "tsc && electron-builder --dir --config electron-builder.yml",
|
||
typecheck: "tsc --noEmit",
|
||
lint: "eslint electron/",
|
||
},
|
||
dependencies: {
|
||
"electron-updater": "^6.3.9",
|
||
"electron-store": "^10.0.0",
|
||
},
|
||
devDependencies: {
|
||
electron: "^35.0.0",
|
||
"electron-builder": "^25.1.8",
|
||
concurrently: "^9.1.0",
|
||
typescript: "^5.7.0",
|
||
"@types/node": "^22.10.0",
|
||
},
|
||
build: {
|
||
productName: displayName,
|
||
appId: "com." + safeName + ".desktop",
|
||
directories: {
|
||
output: "release",
|
||
},
|
||
},
|
||
}, null, 2);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 2. electron-builder.yml Generator
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateElectronBuilderYml(projectName) {
|
||
const safeName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||
const displayName = projectName || "Desktop App";
|
||
|
||
return [
|
||
"# electron-builder.yml — Auto-generated by SF-06",
|
||
"",
|
||
"appId: com." + safeName + ".desktop",
|
||
"productName: " + displayName,
|
||
'copyright: "Copyright © 2026 ' + displayName + '"',
|
||
"",
|
||
"# ─── Directories ─────────────────────────────────",
|
||
"directories:",
|
||
" output: release",
|
||
" buildResources: assets",
|
||
"",
|
||
"# ─── Files ───────────────────────────────────────",
|
||
"files:",
|
||
" - dist/electron/**/*",
|
||
' - "!node_modules/**/*"',
|
||
' - "**/*.node"',
|
||
"",
|
||
"# ─── Extra Resources ─────────────────────────────",
|
||
"extraResources:",
|
||
' - from: "../web/out"',
|
||
' to: "web"',
|
||
" filter:",
|
||
' - "**/*"',
|
||
' - from: "../api/dist"',
|
||
' to: "api"',
|
||
" filter:",
|
||
' - "**/*"',
|
||
' - from: "../api/data"',
|
||
' to: "data"',
|
||
" filter:",
|
||
' - "**/*"',
|
||
"",
|
||
"# ─── macOS ───────────────────────────────────────",
|
||
"mac:",
|
||
" category: public.app-category.productivity",
|
||
" icon: assets/icon.png",
|
||
" target:",
|
||
" - target: dmg",
|
||
" arch:",
|
||
" - x64",
|
||
" - arm64",
|
||
" - target: zip",
|
||
" arch:",
|
||
" - x64",
|
||
" - arm64",
|
||
" darkModeSupport: true",
|
||
" hardenedRuntime: true",
|
||
" gatekeeperAssess: false",
|
||
" entitlements: entitlements.mac.plist",
|
||
" entitlementsInherit: entitlements.mac.plist",
|
||
"",
|
||
"# ─── Windows ─────────────────────────────────────",
|
||
"win:",
|
||
" icon: assets/icon.png",
|
||
" target:",
|
||
" - target: nsis",
|
||
" arch:",
|
||
" - x64",
|
||
" - target: portable",
|
||
" arch:",
|
||
" - x64",
|
||
" publisherName: " + displayName,
|
||
"",
|
||
"nsis:",
|
||
" oneClick: false",
|
||
" perMachine: false",
|
||
" allowToChangeInstallationDirectory: true",
|
||
" installerIcon: assets/icon.ico",
|
||
" uninstallerIcon: assets/icon.ico",
|
||
" installerHeaderIcon: assets/icon.ico",
|
||
" createDesktopShortcut: true",
|
||
" createStartMenuShortcut: true",
|
||
" shortcutName: " + displayName,
|
||
"",
|
||
"# ─── Linux ───────────────────────────────────────",
|
||
"linux:",
|
||
" icon: assets/icon.png",
|
||
" category: Utility",
|
||
" target:",
|
||
" - target: AppImage",
|
||
" arch:",
|
||
" - x64",
|
||
" - target: deb",
|
||
" arch:",
|
||
" - x64",
|
||
" - target: rpm",
|
||
" arch:",
|
||
" - x64",
|
||
" desktop:",
|
||
" Name: " + displayName,
|
||
" Comment: " + displayName + " Desktop Application",
|
||
" Terminal: false",
|
||
" Type: Application",
|
||
" Categories: Utility;",
|
||
"",
|
||
"# ─── Auto Update ─────────────────────────────────",
|
||
"publish:",
|
||
" provider: generic",
|
||
" url: https://releases.example.com/" + safeName + "/",
|
||
" channel: latest",
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 3. main.ts — Main Process
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateMainTs(projectName, webPort, apiPort) {
|
||
const displayName = projectName || "Desktop App";
|
||
|
||
return 'import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";\n' +
|
||
'import { join } from "path";\n' +
|
||
'import { existsSync } from "fs";\n' +
|
||
'import { setupTray } from "./tray";\n' +
|
||
'import { setupIPC } from "./ipc";\n' +
|
||
'import { setupUpdater } from "./updater";\n' +
|
||
'import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";\n' +
|
||
'\n' +
|
||
'// ─── Constants ───────────────────────────────────\n' +
|
||
'const WEB_PORT = ' + webPort + ';\n' +
|
||
'const API_PORT = ' + apiPort + ';\n' +
|
||
'const APP_NAME = "' + displayName + '";\n' +
|
||
'\n' +
|
||
'// ─── Single Instance Lock ────────────────────────\n' +
|
||
'if (!checkSingleInstance()) {\n' +
|
||
' app.quit();\n' +
|
||
' process.exit(0);\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── State ───────────────────────────────────────\n' +
|
||
'let mainWindow: BrowserWindow | null = null;\n' +
|
||
'let apiProcess: ReturnType<typeof import("child_process").spawn> | null = null;\n' +
|
||
'\n' +
|
||
'// ─── Create Window ───────────────────────────────\n' +
|
||
'function createWindow(): BrowserWindow {\n' +
|
||
' const preloadPath = join(__dirname, "preload.js");\n' +
|
||
'\n' +
|
||
' const win = new BrowserWindow({\n' +
|
||
' title: APP_NAME,\n' +
|
||
' width: 1400,\n' +
|
||
' height: 900,\n' +
|
||
' minWidth: 800,\n' +
|
||
' minHeight: 600,\n' +
|
||
' show: false,\n' +
|
||
' icon: getIconPath(),\n' +
|
||
' webPreferences: {\n' +
|
||
' preload: preloadPath,\n' +
|
||
' contextIsolation: true,\n' +
|
||
' nodeIntegration: false,\n' +
|
||
' sandbox: false,\n' +
|
||
' },\n' +
|
||
' titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",\n' +
|
||
' trafficLightPosition: { x: 16, y: 16 },\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── Load content ──\n' +
|
||
' const url = getWebUrl(isDev(), WEB_PORT);\n' +
|
||
' if (url.startsWith("http")) {\n' +
|
||
' win.loadURL(url);\n' +
|
||
' } else {\n' +
|
||
' win.loadFile(url);\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' // ── Show when ready ──\n' +
|
||
' win.once("ready-to-show", () => {\n' +
|
||
' win.show();\n' +
|
||
' if (isDev()) {\n' +
|
||
' win.webContents.openDevTools({ mode: "detach" });\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── External links → default browser ──\n' +
|
||
' win.webContents.setWindowOpenHandler(({ url }) => {\n' +
|
||
' if (url.startsWith("http")) {\n' +
|
||
' require("electron").shell.openExternal(url);\n' +
|
||
' }\n' +
|
||
' return { action: "deny" };\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── Prevent navigation away ──\n' +
|
||
' win.webContents.on("will-navigate", (event, url) => {\n' +
|
||
' const allowed = isDev()\n' +
|
||
' ? url.startsWith("http://localhost:' + webPort + '")\n' +
|
||
' : url.startsWith("file://");\n' +
|
||
' if (!allowed) event.preventDefault();\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' return win;\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── Icon Path ───────────────────────────────────\n' +
|
||
'function getIconPath(): string {\n' +
|
||
' const candidates = [\n' +
|
||
' join(__dirname, "..", "assets", "icon.png"),\n' +
|
||
' join(__dirname, "..", "assets", "icon.icns"),\n' +
|
||
' join(__dirname, "..", "assets", "icon.ico"),\n' +
|
||
' ];\n' +
|
||
' for (const p of candidates) {\n' +
|
||
' if (existsSync(p)) return p;\n' +
|
||
' }\n' +
|
||
' return "";\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── API Server (Production) ─────────────────────\n' +
|
||
'function startApiServer(): void {\n' +
|
||
' if (isDev()) return;\n' +
|
||
'\n' +
|
||
' const apiPath = getApiPath();\n' +
|
||
' if (!apiPath || !existsSync(apiPath)) {\n' +
|
||
' console.warn("[main] API server not found at", apiPath);\n' +
|
||
' return;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const { spawn } = require("child_process");\n' +
|
||
' apiProcess = spawn("node", [apiPath], {\n' +
|
||
' env: {\n' +
|
||
' ...process.env,\n' +
|
||
' NODE_ENV: "production",\n' +
|
||
' PORT: String(API_PORT),\n' +
|
||
' HOST: "127.0.0.1",\n' +
|
||
' },\n' +
|
||
' stdio: ["ignore", "pipe", "pipe"],\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' apiProcess!.stdout?.on("data", (data: Buffer) => {\n' +
|
||
' console.log("[api]", data.toString().trim());\n' +
|
||
' });\n' +
|
||
' apiProcess!.stderr?.on("data", (data: Buffer) => {\n' +
|
||
' console.error("[api]", data.toString().trim());\n' +
|
||
' });\n' +
|
||
' apiProcess!.on("exit", (code: number) => {\n' +
|
||
' console.warn("[main] API server exited with code", code);\n' +
|
||
' apiProcess = null;\n' +
|
||
' });\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'function stopApiServer(): void {\n' +
|
||
' if (apiProcess) {\n' +
|
||
' apiProcess.kill("SIGTERM");\n' +
|
||
' apiProcess = null;\n' +
|
||
' }\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── Menu ────────────────────────────────────────\n' +
|
||
'function buildMenu(): void {\n' +
|
||
' const isMac = process.platform === "darwin";\n' +
|
||
'\n' +
|
||
' const template: Electron.MenuItemConstructorOptions[] = [\n' +
|
||
' ...(isMac\n' +
|
||
' ? [{\n' +
|
||
' label: APP_NAME,\n' +
|
||
' submenu: [\n' +
|
||
' { role: "about" as const },\n' +
|
||
' { type: "separator" as const },\n' +
|
||
' { role: "services" as const },\n' +
|
||
' { type: "separator" as const },\n' +
|
||
' { role: "hide" as const },\n' +
|
||
' { role: "hideOthers" as const },\n' +
|
||
' { role: "unhide" as const },\n' +
|
||
' { type: "separator" as const },\n' +
|
||
' { role: "quit" as const },\n' +
|
||
' ],\n' +
|
||
' }]\n' +
|
||
' : []),\n' +
|
||
' {\n' +
|
||
' label: "Edit",\n' +
|
||
' submenu: [\n' +
|
||
' { role: "undo" },\n' +
|
||
' { role: "redo" },\n' +
|
||
' { type: "separator" },\n' +
|
||
' { role: "cut" },\n' +
|
||
' { role: "copy" },\n' +
|
||
' { role: "paste" },\n' +
|
||
' { role: "selectAll" },\n' +
|
||
' ],\n' +
|
||
' },\n' +
|
||
' {\n' +
|
||
' label: "View",\n' +
|
||
' submenu: [\n' +
|
||
' { role: "reload" },\n' +
|
||
' { role: "forceReload" },\n' +
|
||
' { role: "toggleDevTools" },\n' +
|
||
' { type: "separator" },\n' +
|
||
' { role: "resetZoom" },\n' +
|
||
' { role: "zoomIn" },\n' +
|
||
' { role: "zoomOut" },\n' +
|
||
' { type: "separator" },\n' +
|
||
' { role: "togglefullscreen" },\n' +
|
||
' ],\n' +
|
||
' },\n' +
|
||
' {\n' +
|
||
' label: "Window",\n' +
|
||
' submenu: [\n' +
|
||
' { role: "minimize" },\n' +
|
||
' { role: "zoom" },\n' +
|
||
' ...(isMac\n' +
|
||
' ? [\n' +
|
||
' { type: "separator" as const },\n' +
|
||
' { role: "front" as const },\n' +
|
||
' ]\n' +
|
||
' : [{ role: "close" as const }]),\n' +
|
||
' ],\n' +
|
||
' },\n' +
|
||
' ];\n' +
|
||
'\n' +
|
||
' Menu.setApplicationMenu(Menu.buildFromTemplate(template));\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── App Lifecycle ───────────────────────────────\n' +
|
||
'app.whenReady().then(() => {\n' +
|
||
' startApiServer();\n' +
|
||
' mainWindow = createWindow();\n' +
|
||
' buildMenu();\n' +
|
||
' if (mainWindow) {\n' +
|
||
' setupTray(mainWindow, APP_NAME);\n' +
|
||
' setupIPC(mainWindow);\n' +
|
||
' }\n' +
|
||
' setupUpdater(APP_NAME);\n' +
|
||
'\n' +
|
||
' app.on("activate", () => {\n' +
|
||
' if (BrowserWindow.getAllWindows().length === 0) {\n' +
|
||
' mainWindow = createWindow();\n' +
|
||
' if (mainWindow) {\n' +
|
||
' setupTray(mainWindow, APP_NAME);\n' +
|
||
' setupIPC(mainWindow);\n' +
|
||
' }\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'});\n' +
|
||
'\n' +
|
||
'app.on("window-all-closed", () => {\n' +
|
||
' stopApiServer();\n' +
|
||
' if (process.platform !== "darwin") {\n' +
|
||
' app.quit();\n' +
|
||
' }\n' +
|
||
'});\n' +
|
||
'\n' +
|
||
'app.on("before-quit", () => {\n' +
|
||
' stopApiServer();\n' +
|
||
'});\n' +
|
||
'\n' +
|
||
'app.on("gpu-process-crashed" as any, () => {\n' +
|
||
' console.warn("[main] GPU process crashed — continuing");\n' +
|
||
'});\n' +
|
||
'\n' +
|
||
'process.on("exit", () => {\n' +
|
||
' stopApiServer();\n' +
|
||
'});\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 4. preload.ts — Secure IPC Bridge
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generatePreloadTs() {
|
||
return 'import { contextBridge, ipcRenderer } from "electron";\n' +
|
||
'\n' +
|
||
'// ─── Expose safe API to renderer ─────────────────\n' +
|
||
'contextBridge.exposeInMainWorld("electronAPI", {\n' +
|
||
' // ── App Info ──\n' +
|
||
' getAppVersion: () => ipcRenderer.invoke("app:version"),\n' +
|
||
' getPlatform: () => ipcRenderer.invoke("app:platform"),\n' +
|
||
' getIsDev: () => ipcRenderer.invoke("app:isDev"),\n' +
|
||
'\n' +
|
||
' // ── Window Controls ──\n' +
|
||
' minimizeWindow: () => ipcRenderer.send("window:minimize"),\n' +
|
||
' maximizeWindow: () => ipcRenderer.send("window:maximize"),\n' +
|
||
' closeWindow: () => ipcRenderer.send("window:close"),\n' +
|
||
' isMaximized: () => ipcRenderer.invoke("window:isMaximized"),\n' +
|
||
'\n' +
|
||
' // ── File Dialogs ──\n' +
|
||
' openFile: (options?: Electron.OpenDialogOptions) =>\n' +
|
||
' ipcRenderer.invoke("dialog:openFile", options),\n' +
|
||
' saveFile: (options?: Electron.SaveDialogOptions) =>\n' +
|
||
' ipcRenderer.invoke("dialog:saveFile", options),\n' +
|
||
'\n' +
|
||
' // ── Notifications ──\n' +
|
||
' showNotification: (title: string, body: string) =>\n' +
|
||
' ipcRenderer.send("notification:show", { title, body }),\n' +
|
||
'\n' +
|
||
' // ── Shell ──\n' +
|
||
' openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),\n' +
|
||
' openPath: (path: string) => ipcRenderer.send("shell:openPath", path),\n' +
|
||
'\n' +
|
||
' // ── Store ──\n' +
|
||
' storeGet: (key: string) => ipcRenderer.invoke("store:get", key),\n' +
|
||
' storeSet: (key: string, value: unknown) =>\n' +
|
||
' ipcRenderer.invoke("store:set", key, value),\n' +
|
||
' storeDelete: (key: string) => ipcRenderer.invoke("store:delete", key),\n' +
|
||
'\n' +
|
||
' // ── Updates ──\n' +
|
||
' checkForUpdates: () => ipcRenderer.invoke("updater:check"),\n' +
|
||
' downloadUpdate: () => ipcRenderer.invoke("updater:download"),\n' +
|
||
' quitAndInstall: () => ipcRenderer.send("updater:quitAndInstall"),\n' +
|
||
'\n' +
|
||
' // ── Update Events ──\n' +
|
||
' onUpdateAvailable: (callback: (info: unknown) => void) => {\n' +
|
||
' ipcRenderer.on("updater:available", (_event, info) => callback(info));\n' +
|
||
' },\n' +
|
||
' onUpdateDownloaded: (callback: (info: unknown) => void) => {\n' +
|
||
' ipcRenderer.on("updater:downloaded", (_event, info) => callback(info));\n' +
|
||
' },\n' +
|
||
' onUpdateProgress: (callback: (progress: unknown) => void) => {\n' +
|
||
' ipcRenderer.on("updater:progress", (_event, progress) => callback(progress));\n' +
|
||
' },\n' +
|
||
' onUpdateError: (callback: (error: string) => void) => {\n' +
|
||
' ipcRenderer.on("updater:error", (_event, error) => callback(error));\n' +
|
||
' },\n' +
|
||
'\n' +
|
||
' // ── Remove listener ──\n' +
|
||
' removeAllListeners: (channel: string) => {\n' +
|
||
' ipcRenderer.removeAllListeners(channel);\n' +
|
||
' },\n' +
|
||
'});\n' +
|
||
'\n' +
|
||
'// ─── Type declarations for renderer ──────────────\n' +
|
||
'export interface ElectronAPI {\n' +
|
||
' getAppVersion: () => Promise<string>;\n' +
|
||
' getPlatform: () => Promise<string>;\n' +
|
||
' getIsDev: () => Promise<boolean>;\n' +
|
||
' minimizeWindow: () => void;\n' +
|
||
' maximizeWindow: () => void;\n' +
|
||
' closeWindow: () => void;\n' +
|
||
' isMaximized: () => Promise<boolean>;\n' +
|
||
' openFile: (options?: Electron.OpenDialogOptions) => Promise<string[] | undefined>;\n' +
|
||
' saveFile: (options?: Electron.SaveDialogOptions) => Promise<string | undefined>;\n' +
|
||
' showNotification: (title: string, body: string) => void;\n' +
|
||
' openExternal: (url: string) => void;\n' +
|
||
' openPath: (path: string) => void;\n' +
|
||
' storeGet: (key: string) => Promise<unknown>;\n' +
|
||
' storeSet: (key: string, value: unknown) => Promise<void>;\n' +
|
||
' storeDelete: (key: string) => Promise<void>;\n' +
|
||
' checkForUpdates: () => Promise<void>;\n' +
|
||
' downloadUpdate: () => Promise<void>;\n' +
|
||
' quitAndInstall: () => void;\n' +
|
||
' onUpdateAvailable: (callback: (info: unknown) => void) => void;\n' +
|
||
' onUpdateDownloaded: (callback: (info: unknown) => void) => void;\n' +
|
||
' onUpdateProgress: (callback: (progress: unknown) => void) => void;\n' +
|
||
' onUpdateError: (callback: (error: string) => void) => void;\n' +
|
||
' removeAllListeners: (channel: string) => void;\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'declare global {\n' +
|
||
' interface Window {\n' +
|
||
' electronAPI: ElectronAPI;\n' +
|
||
' }\n' +
|
||
'}\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 5. ipc.ts — IPC Handlers
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateIpcTs() {
|
||
return 'import { ipcMain, app, dialog, shell, BrowserWindow, Notification } from "electron";\n' +
|
||
'import Store from "electron-store";\n' +
|
||
'\n' +
|
||
'const store = new Store() as any;\n' +
|
||
'\n' +
|
||
'export function setupIPC(mainWindow: BrowserWindow): void {\n' +
|
||
' // ── App Info ──\n' +
|
||
' ipcMain.handle("app:version", () => app.getVersion());\n' +
|
||
' ipcMain.handle("app:platform", () => process.platform);\n' +
|
||
' ipcMain.handle("app:isDev", () => !app.isPackaged);\n' +
|
||
'\n' +
|
||
' // ── Window Controls ──\n' +
|
||
' ipcMain.on("window:minimize", () => {\n' +
|
||
' mainWindow?.minimize();\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.on("window:maximize", () => {\n' +
|
||
' if (mainWindow?.isMaximized()) {\n' +
|
||
' mainWindow.unmaximize();\n' +
|
||
' } else {\n' +
|
||
' mainWindow?.maximize();\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.on("window:close", () => {\n' +
|
||
' mainWindow?.close();\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("window:isMaximized", () => {\n' +
|
||
' return mainWindow?.isMaximized() ?? false;\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── File Dialogs ──\n' +
|
||
' ipcMain.handle("dialog:openFile", async (_event, options?) => {\n' +
|
||
' const result = await dialog.showOpenDialog(mainWindow, {\n' +
|
||
' properties: ["openFile"],\n' +
|
||
' ...options,\n' +
|
||
' });\n' +
|
||
' return result.canceled ? undefined : result.filePaths;\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("dialog:saveFile", async (_event, options?) => {\n' +
|
||
' const result = await dialog.showSaveDialog(mainWindow, {\n' +
|
||
' ...options,\n' +
|
||
' });\n' +
|
||
' return result.canceled ? undefined : result.filePath;\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── Notifications ──\n' +
|
||
' ipcMain.on("notification:show", (_event, { title, body }) => {\n' +
|
||
' if (Notification.isSupported()) {\n' +
|
||
' new Notification({ title, body }).show();\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── Shell ──\n' +
|
||
' ipcMain.on("shell:openExternal", (_event, url: string) => {\n' +
|
||
' shell.openExternal(url);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.on("shell:openPath", (_event, path: string) => {\n' +
|
||
' shell.openPath(path);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' // ── Persistent Store ──\n' +
|
||
' ipcMain.handle("store:get", (_event, key: string) => {\n' +
|
||
' return store.get(key);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("store:set", (_event, key: string, value: unknown) => {\n' +
|
||
' store.set(key, value);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("store:delete", (_event, key: string) => {\n' +
|
||
' store.delete(key);\n' +
|
||
' });\n' +
|
||
'}\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 6. tray.ts — System Tray
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateTrayTs() {
|
||
return 'import { Tray, Menu, nativeImage, BrowserWindow, app } from "electron";\n' +
|
||
'import { join } from "path";\n' +
|
||
'import { existsSync } from "fs";\n' +
|
||
'\n' +
|
||
'let tray: Tray | null = null;\n' +
|
||
'\n' +
|
||
'export function setupTray(mainWindow: BrowserWindow, appName: string): void {\n' +
|
||
' const iconPath = getTrayIcon();\n' +
|
||
' if (!iconPath) {\n' +
|
||
' console.warn("[tray] No icon found, skipping tray setup");\n' +
|
||
' return;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const icon = nativeImage.createFromPath(iconPath);\n' +
|
||
' tray = new Tray(icon.resize({ width: 16, height: 16 }));\n' +
|
||
' tray.setToolTip(appName);\n' +
|
||
'\n' +
|
||
' const contextMenu = Menu.buildFromTemplate([\n' +
|
||
' {\n' +
|
||
' label: "Show " + appName,\n' +
|
||
' click: () => {\n' +
|
||
' mainWindow.show();\n' +
|
||
' mainWindow.focus();\n' +
|
||
' },\n' +
|
||
' },\n' +
|
||
' { type: "separator" },\n' +
|
||
' {\n' +
|
||
' label: "Quit",\n' +
|
||
' click: () => {\n' +
|
||
' app.quit();\n' +
|
||
' },\n' +
|
||
' },\n' +
|
||
' ]);\n' +
|
||
'\n' +
|
||
' tray.setContextMenu(contextMenu);\n' +
|
||
'\n' +
|
||
' tray.on("click", () => {\n' +
|
||
' if (mainWindow.isVisible()) {\n' +
|
||
' mainWindow.focus();\n' +
|
||
' } else {\n' +
|
||
' mainWindow.show();\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' tray.on("double-click", () => {\n' +
|
||
' mainWindow.show();\n' +
|
||
' mainWindow.focus();\n' +
|
||
' });\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'function getTrayIcon(): string | null {\n' +
|
||
' const candidates = [\n' +
|
||
' join(__dirname, "..", "assets", "tray-icon.png"),\n' +
|
||
' join(__dirname, "..", "assets", "icon.png"),\n' +
|
||
' join(__dirname, "..", "assets", "icon.ico"),\n' +
|
||
' ];\n' +
|
||
' for (const p of candidates) {\n' +
|
||
' if (existsSync(p)) return p;\n' +
|
||
' }\n' +
|
||
' return null;\n' +
|
||
'}\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 7. updater.ts — Auto Updater
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateUpdaterTs() {
|
||
return 'import { autoUpdater } from "electron-updater";\n' +
|
||
'import { ipcMain, BrowserWindow } from "electron";\n' +
|
||
'\n' +
|
||
'let updateAvailable = false;\n' +
|
||
'let updateDownloaded = false;\n' +
|
||
'\n' +
|
||
'export function setupUpdater(appName: string): void {\n' +
|
||
' autoUpdater.autoDownload = false;\n' +
|
||
' autoUpdater.autoInstallOnAppQuit = true;\n' +
|
||
' autoUpdater.logger = {\n' +
|
||
' info: (msg) => console.log("[updater]", msg),\n' +
|
||
' warn: (msg) => console.warn("[updater]", msg),\n' +
|
||
' error: (msg) => console.error("[updater]", msg),\n' +
|
||
' debug: (msg) => console.debug("[updater]", msg),\n' +
|
||
' };\n' +
|
||
'\n' +
|
||
' autoUpdater.on("checking-for-update", () => {\n' +
|
||
' console.log("[updater] Checking for updates...");\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' autoUpdater.on("update-available", (info) => {\n' +
|
||
' updateAvailable = true;\n' +
|
||
' console.log("[updater] Update available:", info.version);\n' +
|
||
' broadcast("updater:available", info);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' autoUpdater.on("update-not-available", (info) => {\n' +
|
||
' console.log("[updater] Up to date:", info.version);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' autoUpdater.on("download-progress", (progress) => {\n' +
|
||
' broadcast("updater:progress", {\n' +
|
||
' percent: progress.percent,\n' +
|
||
' bytesPerSecond: progress.bytesPerSecond,\n' +
|
||
' transferred: progress.transferred,\n' +
|
||
' total: progress.total,\n' +
|
||
' });\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' autoUpdater.on("update-downloaded", (info) => {\n' +
|
||
' updateDownloaded = true;\n' +
|
||
' console.log("[updater] Update downloaded:", info.version);\n' +
|
||
' broadcast("updater:downloaded", info);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' autoUpdater.on("error", (err) => {\n' +
|
||
' console.error("[updater] Error:", err.message);\n' +
|
||
' broadcast("updater:error", err.message);\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("updater:check", async () => {\n' +
|
||
' try {\n' +
|
||
' const result = await autoUpdater.checkForUpdates();\n' +
|
||
' return result?.updateInfo ?? null;\n' +
|
||
' } catch (err) {\n' +
|
||
' console.error("[updater] Check failed:", err);\n' +
|
||
' return null;\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.handle("updater:download", async () => {\n' +
|
||
' if (!updateAvailable) return;\n' +
|
||
' try {\n' +
|
||
' await autoUpdater.downloadUpdate();\n' +
|
||
' } catch (err) {\n' +
|
||
' console.error("[updater] Download failed:", err);\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' ipcMain.on("updater:quitAndInstall", () => {\n' +
|
||
' if (updateDownloaded) {\n' +
|
||
' autoUpdater.quitAndInstall(false, true);\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' setTimeout(() => {\n' +
|
||
' autoUpdater.checkForUpdates().catch(() => {});\n' +
|
||
' }, 10_000);\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'function broadcast(channel: string, data: unknown): void {\n' +
|
||
' for (const win of BrowserWindow.getAllWindows()) {\n' +
|
||
' if (!win.isDestroyed()) {\n' +
|
||
' win.webContents.send(channel, data);\n' +
|
||
' }\n' +
|
||
' }\n' +
|
||
'}\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 8. utils.ts — Shared Utilities
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateUtilsTs(webPort, apiPort) {
|
||
return 'import { app } from "electron";\n' +
|
||
'import { join } from "path";\n' +
|
||
'import { existsSync } from "fs";\n' +
|
||
'\n' +
|
||
'// ─── Single Instance Lock ────────────────────────\n' +
|
||
'export function checkSingleInstance(): boolean {\n' +
|
||
' const gotLock = app.requestSingleInstanceLock();\n' +
|
||
' if (!gotLock) {\n' +
|
||
' app.quit();\n' +
|
||
' return false;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' app.on("second-instance", (_event, _argv, _cwd) => {\n' +
|
||
' const windows = require("electron").BrowserWindow.getAllWindows();\n' +
|
||
' if (windows.length > 0) {\n' +
|
||
' const win = windows[0];\n' +
|
||
' if (win.isMinimized()) win.restore();\n' +
|
||
' win.focus();\n' +
|
||
' }\n' +
|
||
' });\n' +
|
||
'\n' +
|
||
' return true;\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── Dev mode detection ──────────────────────────\n' +
|
||
'export function isDev(): boolean {\n' +
|
||
' return !app.isPackaged;\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── Web URL ─────────────────────────────────────\n' +
|
||
'export function getWebUrl(dev: boolean, port: number): string {\n' +
|
||
' if (dev) {\n' +
|
||
' return "http://localhost:" + port;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const appPath = app.isPackaged\n' +
|
||
' ? join(process.resourcesPath, "web")\n' +
|
||
' : join(__dirname, "..", "..", "web", "out");\n' +
|
||
'\n' +
|
||
' const staticIndex = join(appPath, "index.html");\n' +
|
||
' if (existsSync(staticIndex)) {\n' +
|
||
' return staticIndex;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const standalone = join(appPath, ".next", "standalone", "server.js");\n' +
|
||
' if (existsSync(standalone)) {\n' +
|
||
' return "http://localhost:' + webPort + '";\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const fallback = join(appPath, "index.html");\n' +
|
||
' if (existsSync(fallback)) {\n' +
|
||
' return fallback;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' console.warn("[utils] No web build found at", appPath);\n' +
|
||
' return "http://localhost:' + webPort + '";\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── API Path ────────────────────────────────────\n' +
|
||
'export function getApiPath(): string | null {\n' +
|
||
' if (app.isPackaged) {\n' +
|
||
' const packed = join(process.resourcesPath, "api", "dist", "index.js");\n' +
|
||
' if (existsSync(packed)) return packed;\n' +
|
||
' const packedSrc = join(process.resourcesPath, "api", "src", "index.js");\n' +
|
||
' if (existsSync(packedSrc)) return packedSrc;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' const devPath = join(__dirname, "..", "..", "api", "dist", "index.js");\n' +
|
||
' if (existsSync(devPath)) return devPath;\n' +
|
||
'\n' +
|
||
' const devSrc = join(__dirname, "..", "..", "api", "src", "index.js");\n' +
|
||
' if (existsSync(devSrc)) return devSrc;\n' +
|
||
'\n' +
|
||
' return null;\n' +
|
||
'}\n' +
|
||
'\n' +
|
||
'// ─── Get API port from env ───────────────────────\n' +
|
||
'export function getApiPort(): number {\n' +
|
||
' return ' + apiPort + ';\n' +
|
||
'}\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 9. tsconfig.json Generator
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateTsConfig() {
|
||
return JSON.stringify({
|
||
compilerOptions: {
|
||
target: "ES2022",
|
||
module: "commonjs",
|
||
moduleResolution: "node",
|
||
lib: ["ES2022"],
|
||
outDir: "dist",
|
||
rootDir: "electron",
|
||
strict: true,
|
||
esModuleInterop: true,
|
||
skipLibCheck: true,
|
||
forceConsistentCasingInFileNames: true,
|
||
resolveJsonModule: true,
|
||
declaration: false,
|
||
sourceMap: true,
|
||
noUnusedLocals: false,
|
||
noUnusedParameters: false,
|
||
types: ["node"],
|
||
},
|
||
include: ["electron/**/*.ts"],
|
||
exclude: ["node_modules", "dist", "release"],
|
||
}, null, 2);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 10. README.md Generator
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateReadme(projectName) {
|
||
const displayName = projectName || "Desktop App";
|
||
const bt = String.fromCharCode(96); // backtick
|
||
const fence = bt + bt + bt;
|
||
|
||
return "# " + displayName + " — Desktop\n" +
|
||
"\n" +
|
||
"> Electron desktop application wrapping the " + displayName + " fullstack web project.\n" +
|
||
"\n" +
|
||
"## Architecture\n" +
|
||
"\n" +
|
||
fence + "\n" +
|
||
"Web Fullstack Project\n" +
|
||
" ×\n" +
|
||
" Desktop Shell (Electron)\n" +
|
||
" =\n" +
|
||
" Desktop Application\n" +
|
||
fence + "\n" +
|
||
"\n" +
|
||
"- **Main Process** — Window management, menu, tray, IPC, auto-update\n" +
|
||
"- **Preload** — Secure bridge between main and renderer\n" +
|
||
"- **Renderer** — Your existing web frontend (Next.js)\n" +
|
||
"\n" +
|
||
"## Development\n" +
|
||
"\n" +
|
||
fence + "bash\n" +
|
||
"# Install dependencies\n" +
|
||
"npm install\n" +
|
||
"\n" +
|
||
"# Start dev mode (web + api + electron concurrently)\n" +
|
||
"npm run dev\n" +
|
||
fence + "\n" +
|
||
"\n" +
|
||
"Dev mode:\n" +
|
||
"- Web frontend: " + bt + "http://localhost:3000" + bt + "\n" +
|
||
"- API backend: " + bt + "http://localhost:3001" + bt + "\n" +
|
||
"- Electron loads the web URL directly\n" +
|
||
"\n" +
|
||
"## Build\n" +
|
||
"\n" +
|
||
fence + "bash\n" +
|
||
"# Build for current platform\n" +
|
||
"npm run build\n" +
|
||
"\n" +
|
||
"# Platform-specific builds\n" +
|
||
"npm run build:electron:mac\n" +
|
||
"npm run build:electron:win\n" +
|
||
"npm run build:electron:linux\n" +
|
||
fence + "\n" +
|
||
"\n" +
|
||
"Output: " + bt + "release/" + bt + " directory with platform installers.\n" +
|
||
"\n" +
|
||
"## Project Structure\n" +
|
||
"\n" +
|
||
fence + "\n" +
|
||
"electron/\n" +
|
||
"├── main.ts — Main process (BrowserWindow, Menu, lifecycle)\n" +
|
||
"├── preload.ts — Secure IPC bridge (contextBridge)\n" +
|
||
"├── ipc.ts — IPC handlers (dialogs, store, notifications)\n" +
|
||
"├── tray.ts — System tray\n" +
|
||
"├── updater.ts — Auto-update (electron-updater)\n" +
|
||
"└── utils.ts — Shared utilities\n" +
|
||
fence + "\n" +
|
||
"\n" +
|
||
"## IPC API (available in renderer)\n" +
|
||
"\n" +
|
||
fence + "typescript\n" +
|
||
"window.electronAPI.getAppVersion()\n" +
|
||
"window.electronAPI.getPlatform()\n" +
|
||
"window.electronAPI.minimizeWindow()\n" +
|
||
"window.electronAPI.maximizeWindow()\n" +
|
||
"window.electronAPI.closeWindow()\n" +
|
||
"window.electronAPI.openFile(options?)\n" +
|
||
"window.electronAPI.saveFile(options?)\n" +
|
||
"window.electronAPI.showNotification(title, body)\n" +
|
||
"window.electronAPI.openExternal(url)\n" +
|
||
"window.electronAPI.storeGet(key)\n" +
|
||
"window.electronAPI.storeSet(key, value)\n" +
|
||
"window.electronAPI.checkForUpdates()\n" +
|
||
"window.electronAPI.downloadUpdate()\n" +
|
||
"window.electronAPI.quitAndInstall()\n" +
|
||
fence + "\n" +
|
||
"\n" +
|
||
"## Auto Update\n" +
|
||
"\n" +
|
||
"Configured via " + bt + "electron-builder.yml" + bt + ". Uses " + bt + "electron-updater" + bt + " with generic provider.\n" +
|
||
"Set your release URL in the " + bt + "publish" + bt + " section.\n";
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 11. Placeholder Icon Generator
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generatePlaceholderIcon() {
|
||
// Minimal valid 1x1 transparent PNG
|
||
return Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
|
||
0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02,
|
||
0x00, 0x01, 0xe5, 0x27, 0xde, 0xfc, 0x00, 0x00,
|
||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42,
|
||
0x60, 0x82,
|
||
]);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 12. macOS Entitlements
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function generateEntitlements() {
|
||
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
|
||
'<plist version="1.0">\n' +
|
||
'<dict>\n' +
|
||
' <key>com.apple.security.cs.allow-jit</key>\n' +
|
||
' <true/>\n' +
|
||
' <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n' +
|
||
' <true/>\n' +
|
||
' <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n' +
|
||
' <true/>\n' +
|
||
' <key>com.apple.security.network.client</key>\n' +
|
||
' <true/>\n' +
|
||
' <key>com.apple.security.network.server</key>\n' +
|
||
' <true/>\n' +
|
||
' <key>com.apple.security.files.user-selected.read-write</key>\n' +
|
||
' <true/>\n' +
|
||
'</dict>\n' +
|
||
'</plist>\n';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 13. Main Builder Function
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Build Electron desktop app wrapper for a fullstack project.
|
||
*
|
||
* @param {object} options
|
||
* @param {string} options.projectName — Project display name
|
||
* @param {number} [options.webPort=3000] — Dev web port
|
||
* @param {number} [options.apiPort=3001] — Dev API port
|
||
* @returns {object} { files, stats, error, message }
|
||
*/
|
||
export function buildElectron(options = {}) {
|
||
const {
|
||
projectName = "Desktop App",
|
||
webPort = 3000,
|
||
apiPort = 3001,
|
||
} = options;
|
||
|
||
const files = {};
|
||
|
||
files["package.json"] = generatePackageJson(projectName, webPort, apiPort);
|
||
files["electron-builder.yml"] = generateElectronBuilderYml(projectName);
|
||
files["tsconfig.json"] = generateTsConfig();
|
||
files["electron/main.ts"] = generateMainTs(projectName, webPort, apiPort);
|
||
files["electron/preload.ts"] = generatePreloadTs();
|
||
files["electron/ipc.ts"] = generateIpcTs();
|
||
files["electron/tray.ts"] = generateTrayTs();
|
||
files["electron/updater.ts"] = generateUpdaterTs();
|
||
files["electron/utils.ts"] = generateUtilsTs(webPort, apiPort);
|
||
files["assets/icon.png"] = generatePlaceholderIcon();
|
||
files["entitlements.mac.plist"] = generateEntitlements();
|
||
files["README.md"] = generateReadme(projectName);
|
||
|
||
const allPaths = Object.keys(files);
|
||
const stats = {
|
||
totalFiles: allPaths.length,
|
||
electronFiles: allPaths.filter(p => p.startsWith("electron/")).length,
|
||
configFiles: allPaths.filter(p =>
|
||
p.endsWith(".json") || p.endsWith(".yml") || p.endsWith(".plist")
|
||
).length,
|
||
};
|
||
|
||
return { files, stats, error: null, message: null };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 14. File I/O
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
export function writeElectron(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 });
|
||
if (typeof content === "string") {
|
||
writeFileSync(fullPath, content, "utf-8");
|
||
} else {
|
||
writeFileSync(fullPath, content);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// 15. CLI Entry
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
function parseArgs() {
|
||
const args = process.argv.slice(2);
|
||
const opts = {
|
||
input: null,
|
||
prd: null,
|
||
arch: null,
|
||
output: null,
|
||
webPort: 3000,
|
||
apiPort: 3001,
|
||
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] === "--prd" && args[i + 1]) opts.prd = args[++i];
|
||
else if (args[i] === "--arch" && args[i + 1]) opts.arch = args[++i];
|
||
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
|
||
else if (args[i] === "--web-port" && args[i + 1]) opts.webPort = parseInt(args[++i], 10);
|
||
else if (args[i] === "--api-port" && args[i + 1]) opts.apiPort = 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(
|
||
"\nElectron Builder Agent — SF-06\n\n" +
|
||
"Wrap a fullstack web project as an Electron desktop app.\n\n" +
|
||
"Usage:\n" +
|
||
" node scripts/electron-builder-agent.mjs --input <fullstack-dir> [options]\n" +
|
||
" node scripts/electron-builder-agent.mjs --prd <path> --arch <path> [options]\n\n" +
|
||
"Options:\n" +
|
||
" --input <dir> SF-05 output directory (apps/web, apps/api, packages/)\n" +
|
||
" --prd <path> PRD JSON (SF-01 output)\n" +
|
||
" --arch <path> Architecture JSON (SF-02 output)\n" +
|
||
" --output <dir> Output directory (default: <input>/apps/desktop)\n" +
|
||
" --web-port <port> Dev web port (default: 3000)\n" +
|
||
" --api-port <port> Dev API port (default: 3001)\n" +
|
||
" --verbose Verbose output\n" +
|
||
" --help Show this help\n"
|
||
);
|
||
return;
|
||
}
|
||
|
||
let projectName = "Desktop App";
|
||
|
||
if (opts.prd) {
|
||
try {
|
||
const prd = JSON.parse(readFileSync(opts.prd, "utf-8"));
|
||
if (prd.projectName) projectName = prd.projectName;
|
||
} catch {}
|
||
}
|
||
|
||
if (opts.input) {
|
||
try {
|
||
const rootPkg = JSON.parse(readFileSync(resolve(opts.input, "package.json"), "utf-8"));
|
||
if (rootPkg.name) projectName = rootPkg.name;
|
||
} catch {}
|
||
}
|
||
|
||
const result = buildElectron({
|
||
projectName,
|
||
webPort: opts.webPort,
|
||
apiPort: opts.apiPort,
|
||
});
|
||
|
||
if (result.error) {
|
||
console.error(result.message);
|
||
process.exit(1);
|
||
}
|
||
|
||
const outputDir = opts.output
|
||
? resolve(opts.output)
|
||
: opts.input
|
||
? resolve(opts.input, "apps", "desktop")
|
||
: resolve(WORKSPACE, "desktop");
|
||
|
||
writeElectron(result, outputDir);
|
||
|
||
if (opts.verbose) {
|
||
console.error("Project: " + projectName);
|
||
console.error("Files: " + result.stats.totalFiles);
|
||
console.error("Electron files: " + result.stats.electronFiles);
|
||
console.error("Config files: " + result.stats.configFiles);
|
||
}
|
||
|
||
console.log(JSON.stringify({
|
||
projectName,
|
||
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();
|
||
}
|