feat: unify workspace design and portable packaging

Establish shared scoped UI primitives and make repeatable Windows builds preserve existing user data safely.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 00:43:07 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent e8dc4d03fd
commit 09e9fbf5e2
14 changed files with 2306 additions and 640 deletions
+244 -9
View File
@@ -1,32 +1,263 @@
const { spawnSync } = require('node:child_process')
const {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync
statSync,
writeFileSync
} = require('node:fs')
const { join } = require('node:path')
const { dirname, join, parse, resolve } = require('node:path')
const root = join(__dirname, '..')
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8')
)
const outputRoot = join(root, 'dist')
const stagingRoot = join(outputRoot, '.portable-stage-x64')
const stagingRoot = join(
outputRoot,
`.portable-stage-x64-${process.pid}`
)
const unpackedPath = join(stagingRoot, 'win-unpacked')
const portableName = `GoodBuddy-${packageJson.version}-win-x64-portable`
const portablePath = join(outputRoot, portableName)
const portablePath = process.env.GOODBUDDY_OUT_DIR
? resolve(process.env.GOODBUDDY_OUT_DIR)
: join(outputRoot, portableName)
const markerName = '.goodbuddy-portable.json'
if (process.platform !== 'win32' || process.arch !== 'x64') {
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
}
if (statSync(portablePath, { throwIfNoEntry: false })) {
if (
portablePath === parse(portablePath).root ||
portablePath === root ||
portablePath === outputRoot
) {
throw new Error(
`输出目录已存在,请先移动或删除${portablePath}`
`拒绝使用不安全的输出目录:${portablePath}`
)
}
function assertOutputUnlocked(directory) {
if (!existsSync(directory)) {
return
}
const probe = `${directory}.build-lock-check-${process.pid}`
try {
renameSync(directory, probe)
renameSync(probe, directory)
} catch (error) {
if (!existsSync(directory) && existsSync(probe)) {
try {
renameSync(probe, directory)
} catch {
// The original lock error is more useful.
}
}
if (
error?.code === 'EPERM' ||
error?.code === 'EBUSY' ||
error?.code === 'EACCES'
) {
throw new Error(
`无法更新 ${directory},请先关闭其中正在运行的 GoodBuddy.exe`,
{ cause: error }
)
}
throw error
}
}
function ensureElectronRuntime() {
const electronRoot = join(root, 'node_modules', 'electron')
const electronDist = join(electronRoot, 'dist')
const executable = join(electronDist, 'electron.exe')
const versionFile = join(electronDist, 'version')
const installedPackagePath = join(electronRoot, 'package.json')
const lock = JSON.parse(
readFileSync(join(root, 'package-lock.json'), 'utf8')
)
const lockedVersion =
lock.packages?.['node_modules/electron']?.version
if (
!existsSync(installedPackagePath) ||
!existsSync(executable) ||
!existsSync(versionFile)
) {
throw new Error('缺少本地 Electron 运行时,请先运行 npm ci')
}
const installedVersion = JSON.parse(
readFileSync(installedPackagePath, 'utf8')
).version
const runtimeVersion = readFileSync(versionFile, 'utf8')
.trim()
.replace(/^v/u, '')
if (
!lockedVersion ||
installedVersion !== lockedVersion ||
runtimeVersion !== lockedVersion
) {
throw new Error(
`Electron 版本不一致(锁定 ${lockedVersion ?? '缺失'},依赖 ${installedVersion},运行时 ${runtimeVersion}),请运行 npm ci`
)
}
return electronDist
}
function copyDirectoryContents(source, destination) {
mkdirSync(destination, { recursive: true })
for (const entry of readdirSync(source, { withFileTypes: true })) {
cpSync(
join(source, entry.name),
join(destination, entry.name),
{ recursive: entry.isDirectory(), force: true }
)
}
}
function portableRequiredPaths(directory) {
return [
join(directory, 'GoodBuddy.exe'),
join(directory, 'resources', 'app.asar'),
join(
directory,
'resources',
'runtimes',
'opencode',
'opencode.exe'
),
join(
directory,
'resources',
'runtimes',
'continue',
'package.json'
)
]
}
function assertPortableOutput(directory) {
const requiredPaths = portableRequiredPaths(directory)
const missing = requiredPaths.filter(
(filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile()
)
if (missing.length > 0) {
throw new Error(
`Portable 目录缺少必要文件:${missing.join(', ')}`
)
}
}
function assertReplaceableOutput(directory) {
if (!existsSync(directory)) {
return
}
const entries = readdirSync(directory)
if (entries.every((entry) => entry === 'data')) {
return
}
try {
const marker = JSON.parse(
readFileSync(join(directory, markerName), 'utf8')
)
if (
marker.productName === 'GoodBuddy' &&
marker.formatVersion === 1
) {
return
}
} catch {
// Fall back to recognizing portable outputs created before markers.
}
if (
portableRequiredPaths(directory).every(
(filePath) =>
statSync(filePath, { throwIfNoEntry: false })?.isFile()
)
) {
return
}
throw new Error(
`拒绝覆盖未识别的目录:${directory}。请选择空目录或已有的 GoodBuddy portable 目录`
)
}
function writePortableMarker(directory) {
writeFileSync(
join(directory, markerName),
`${JSON.stringify({
formatVersion: 1,
productName: 'GoodBuddy',
version: packageJson.version
}, null, 2)}\n`,
'utf8'
)
}
function replacePortableOutput(source, destination) {
const replacement = `${destination}.replacement-${process.pid}`
const backup = `${destination}.previous-${process.pid}`
mkdirSync(dirname(destination), { recursive: true })
rmSync(replacement, { recursive: true, force: true })
rmSync(backup, { recursive: true, force: true })
copyDirectoryContents(source, replacement)
try {
writePortableMarker(replacement)
assertPortableOutput(replacement)
if (!existsSync(destination)) {
renameSync(replacement, destination)
return
}
assertReplaceableOutput(destination)
assertOutputUnlocked(destination)
renameSync(destination, backup)
try {
renameSync(replacement, destination)
const preservedData = join(backup, 'data')
if (existsSync(preservedData)) {
const destinationData = join(destination, 'data')
if (existsSync(destinationData)) {
throw new Error(
`新构建意外包含 data 目录,无法保留 ${preservedData}`
)
}
renameSync(preservedData, destinationData)
}
assertPortableOutput(destination)
} catch (error) {
try {
const movedData = join(destination, 'data')
const originalData = join(backup, 'data')
if (existsSync(movedData) && !existsSync(originalData)) {
renameSync(movedData, originalData)
}
if (existsSync(destination)) {
renameSync(destination, replacement)
}
if (existsSync(backup)) {
renameSync(backup, destination)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`更新失败且无法恢复原 portable 目录:${destination}`,
{ cause: rollbackError }
)
}
throw error
}
rmSync(backup, { recursive: true, force: true })
} finally {
rmSync(replacement, { recursive: true, force: true })
}
}
const electronDist = ensureElectronRuntime()
mkdirSync(outputRoot, { recursive: true })
rmSync(stagingRoot, { recursive: true, force: true })
@@ -37,7 +268,7 @@ const result = spawnSync(
'--dir',
'--x64',
`--config.directories.output=${stagingRoot}`,
'--config.electronDist=node_modules/electron/dist'
`--config.electronDist=${electronDist}`
],
{
cwd: root,
@@ -62,8 +293,12 @@ if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
rmSync(stagingRoot, { recursive: true, force: true })
throw new Error('Electron Builder 未生成 portable 目录')
}
renameSync(unpackedPath, portablePath)
rmSync(stagingRoot, { recursive: true, force: true })
try {
assertPortableOutput(unpackedPath)
replacePortableOutput(unpackedPath, portablePath)
} finally {
rmSync(stagingRoot, { recursive: true, force: true })
}
console.log(`Portable 目录构建完成:${portablePath}`)
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)