feat(desktop): three-platform packaging and CI matrix (M3) (#177)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-04 18:32:13 +08:00
1 parent a4415cdb4b
commit 747077cabb
11 files changed
+2150 -7

No files matched your search

+1
View File
@@ -0,0 +1 @@
stage/
+59
View File
@@ -0,0 +1,59 @@
# electron-builder config (design § "桌面端原型 · 打包与更新", milestone M3).
#
# The app directory is the pnpm-deploy staging tree assembled by scripts/stage.mjs —
# a portable, self-contained node_modules (workspace packages materialized) plus the
# shell bundle and the server's web-dist.
#
# asar is off on purpose: the shell forks the server as a child process, the skill
# library reads its .md files from disk, and agent commands spawn real shells — plain
# files sidestep every asar edge (fork-from-archive, unpacked-tree resolution splits)
# at the cost of some inodes.
#
# M3 ships unsigned artifacts: macOS signing/notarization and Windows code signing are
# milestone M4, together with electron-updater.
appId: com.prismshadow.penguinharness
productName: PenguinHarness
directories:
app: stage/app
output: stage/out
asar: false
# The staged tree is complete and has no native modules; a rebuild would only fail
# against the pruned package.json.
npmRebuild: false
nodeGypRebuild: false
mac:
category: public.app-category.developer-tools
# No Developer ID yet (M4): identity null skips codesign instead of failing.
identity: null
target:
- target: dmg
arch: [arm64, x64]
- target: zip
arch: [arm64, x64]
win:
target:
- target: nsis
arch: [x64]
# MinGit, downloaded by the CI job into stage/minigit (empty for local non-Windows
# builds): lands under resources/git, and the shell advertises git/usr/bin/sh.exe as
# PENGUIN_BUNDLED_SHELL so the agent shell behaves like the npm-installed package.
extraResources:
- from: stage/minigit
to: git
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
linux:
# The scoped package name is not a valid executable file name.
executableName: penguin-harness
category: Development
maintainer: Prism Shadow <zheng@prismshadow.com>
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
+8 -2
View File
@@ -9,7 +9,12 @@
"build": "tsup",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"start": "electron ."
"start": "node scripts/preflight.mjs && electron .",
"stage": "node scripts/stage.mjs",
"pack": "node scripts/stage.mjs && electron-builder",
"pack:linux": "node scripts/stage.mjs && electron-builder --linux",
"pack:mac": "node scripts/stage.mjs && electron-builder --mac",
"pack:win": "node scripts/stage.mjs && electron-builder --win"
},
"dependencies": {
"@prismshadow/penguin-core": "workspace:*",
@@ -20,6 +25,7 @@
"electron": "^43.2.0",
"tsup": "^8.3.0",
"typescript": "^5.6.0",
"vitest": "^3.2.6"
"vitest": "^3.2.6",
"electron-builder": "^26.0.12"
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Dev preflight for `pnpm --dir packages/desktop start` (and the root `pnpm desktop`):
* verify everything a source run needs, and fail with the actual fix instead of a bare
* ERR_MODULE_NOT_FOUND. The classic trap: this package's node_modules holds pnpm
* *injected copies* of the workspace packages, which only sync while building THROUGH
* pnpm — a stale copy surfaces as `Cannot find module …/penguin-server/dist/lock.js`.
*/
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(path.join(pkgDir, "package.json"));
const problems = [];
for (const [what, rel] of [
["the desktop shell build", "dist/main.js"],
["the injected server copy", "node_modules/@prismshadow/penguin-server/dist/lock.js"],
["the web frontend build", "../web/dist/index.html"],
]) {
if (!fs.existsSync(path.join(pkgDir, rel))) {
problems.push(
`Missing ${what} (${rel}). Run \`pnpm -r build\` at the repo root — builds through pnpm also sync the injected workspace copies under node_modules.`,
);
}
}
// `require("electron")` resolves to the platform binary path; the package exists even
// when its postinstall (the binary download) was skipped by the package manager.
try {
const electronBinary = require("electron");
if (typeof electronBinary !== "string" || !fs.existsSync(electronBinary)) {
throw new Error("binary missing");
}
} catch {
problems.push(
"The Electron binary is missing. Run `node node_modules/electron/install.js` in packages/desktop (its postinstall was skipped; pnpm allows it via the workspace allowBuilds entry).",
);
}
if (problems.length > 0) {
console.error("penguin-desktop preflight failed:\n");
for (const p of problems) console.error(` - ${p}`);
console.error("");
process.exit(1);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Assemble the self-contained app directory electron-builder packs (stage/app).
*
* `pnpm deploy --prod` materializes this package plus its production dependency tree —
* including the workspace packages — into a portable directory whose symlinks all stay
* inside it (verified: the server boots from the deploy dir as-is). On top of that:
* - prune dev files (sources, configs) so only dist/, node_modules/ and package.json ship;
* - copy the web build to `node_modules/@prismshadow/penguin-server/web-dist`, the npm
* package layout the server's static-hosting lookup checks first;
* - ensure `stage/minigit` exists (may be empty): the Windows CI job downloads MinGit
* into it, and electron-builder's win extraResources entry must always have a source.
*
* Run from anywhere; all paths are derived from this file's location.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(pkgDir, "..", "..");
const stageDir = path.join(pkgDir, "stage");
const appDir = path.join(stageDir, "app");
fs.rmSync(stageDir, { recursive: true, force: true });
fs.mkdirSync(stageDir, { recursive: true });
console.log("[stage] pnpm deploy --prod → stage/app");
// Scrub inherited npm_config_* vars: when this script runs under `pnpm run`, they leak
// into the child pnpm and derail its argument parsing.
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.toLowerCase().startsWith("npm_config_")),
);
// Windows: pnpm is pnpm.cmd, which Node only spawns through a shell (and .cmd spawning
// without one is blocked since the CVE-2024-27980 hardening). With a shell, args are
// joined verbatim, so quote them — appDir may contain spaces.
const isWindows = process.platform === "win32";
const deployArgs = ["--filter", "@prismshadow/penguin-desktop", "deploy", "--prod", appDir];
execFileSync(
isWindows ? "pnpm.cmd" : "pnpm",
isWindows ? deployArgs.map((a) => `"${a}"`) : deployArgs,
{ cwd: repoRoot, stdio: "inherit", env, shell: isWindows },
);
// Keep only what the packaged app runs.
const keep = new Set(["dist", "node_modules", "package.json"]);
for (const entry of fs.readdirSync(appDir)) {
if (!keep.has(entry)) fs.rmSync(path.join(appDir, entry), { recursive: true, force: true });
}
// Strip scripts and devDependencies from the staged package.json but KEEP the
// dependencies pnpm deploy wrote: electron-builder's node-module collector walks them
// to decide what ships — with no dependencies it falls back to scanning the PROJECT
// directory's node_modules (the injected copies, which lack web-dist) and rebuilds the
// wrong tree.
const stagedPkgPath = path.join(appDir, "package.json");
const stagedPkg = JSON.parse(fs.readFileSync(stagedPkgPath, "utf8"));
delete stagedPkg.scripts;
delete stagedPkg.devDependencies;
stagedPkg.private = true;
// Unscoped name: deb/AppImage internals derive package and executable names from it,
// and "@prismshadow/…" is invalid there. The app-root name plays no role in module
// resolution, so the packaged name can differ from the workspace package name.
stagedPkg.name = "penguin-harness-desktop";
// fpm (deb) refuses to build without a homepage.
stagedPkg.homepage = "https://github.com/Prism-Shadow/penguin-harness";
fs.writeFileSync(stagedPkgPath, JSON.stringify(stagedPkg, null, 2) + "\n");
const webDist = path.join(repoRoot, "packages", "web", "dist");
if (!fs.existsSync(path.join(webDist, "index.html"))) {
console.error("[stage] packages/web/dist is missing — run `pnpm -r build` first.");
process.exit(1);
}
// The @prismshadow/penguin-server entry is a symlink into .pnpm; copying THROUGH it
// lands the files in the real package dir, which is exactly where the server looks.
const serverPkg = path.join(appDir, "node_modules", "@prismshadow", "penguin-server");
fs.cpSync(webDist, path.join(serverPkg, "web-dist"), { recursive: true, dereference: true });
fs.mkdirSync(path.join(stageDir, "minigit"), { recursive: true });
console.log("[stage] done:", appDir);
+32 -2
View File
@@ -7,8 +7,9 @@
*/
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { utilityProcess } from "electron";
import { app, utilityProcess } from "electron";
import type { UtilityProcess } from "electron";
import { appOriginFor, parsePortFile } from "./util.js";
@@ -30,11 +31,39 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** The server package's entry file — forked by path, resolved through node_modules. */
/**
* The server package's entry file — forked by path. Packaged builds ship the staged
* pnpm-deploy tree inside the app directory (asar is off, see electron-builder.yml),
* so the path is a plain file; dev runs resolve through this package's node_modules.
*/
function serverEntryPath(): string {
if (app.isPackaged) {
return path.join(
app.getAppPath(),
"node_modules",
"@prismshadow",
"penguin-server",
"dist",
"index.js",
);
}
return fileURLToPath(import.meta.resolve("@prismshadow/penguin-server"));
}
/**
* Extra environment for the forked server. Windows packages carry MinGit under
* resources/git (electron-builder extraResources): advertising its sh.exe as
* PENGUIN_BUNDLED_SHELL gives the agent shell the same deterministic POSIX behavior as
* the npm-installed package (core's shell resolver prefers a user-installed Git for
* Windows from PATH, then this bundle, before falling back to PowerShell). An existing
* value is respected.
*/
function bundledShellEnv(): Record<string, string> {
if (process.platform !== "win32" || process.env.PENGUIN_BUNDLED_SHELL) return {};
const sh = path.join(process.resourcesPath, "git", "usr", "bin", "sh.exe");
return fs.existsSync(sh) ? { PENGUIN_BUNDLED_SHELL: sh } : {};
}
async function waitForPortFile(file: string, exited: () => boolean): Promise<number> {
const deadline = Date.now() + PORT_FILE_TIMEOUT_MS;
for (;;) {
@@ -90,6 +119,7 @@ export async function startEmbeddedServer(opts: {
stdio: "pipe",
env: {
...process.env,
...bundledShellEnv(),
PENGUIN_HOME: opts.dataRoot,
HOST: "127.0.0.1",
PORT: "0",