@@ -1,6 +1,6 @@
import fs from "fs" ;
import path from "path" ;
import { execSync } from "child_process" ;
import { spawn } from "child_process" ;
// ============================================================
// 版本更新模块
@@ -57,38 +57,78 @@ function consoleErr(tag: string, msg: string): void {
}
/**
* 执行命令并记录到控制台
* 异步 执行命令,实时流式输出(不阻塞事件循环)
* @param command 可执行文件
* @param args 参数列表
* @param tag 日志标签
* @param options.onLine 每行输出回调(用于 SSE 推送到前端)
*/
function execSyncAndLog (
function execAsync (
command : string ,
args : string [ ] ,
tag : string ,
options : Parameters < typeof execSync > [ 1 ] & { encoding ? : "utf-8" } = { }
) : string {
consoleLog ( tag , ` 执行: ${ command } ` ) ;
try {
const stdout = execSync ( command , {
encoding : "utf-8" ,
options ? : {
timeout? : number ;
cwd? : string ;
onLine ? : ( line : string ) = > void ;
}
) : Promise < void > {
return new Promise ( ( resolve , reject ) = > {
const desc = ` ${ command } ${ args . join ( " " ) } ` ;
consoleLog ( tag , ` 执行: ${ desc } ` ) ;
const child = spawn ( command , args , {
cwd : options?.cwd ,
timeout : options?.timeout ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
windowsHide : true ,
. . . options ,
} ) ;
const out = stdout ? . trim ( ) ;
if ( out ) {
const lines = out . split ( "\n" ) . filter ( Boolean ) ;
// 最多打印 5 行,避免刷屏
lines . slice ( 0 , 5 ) . forEach ( ( l ) = > consoleLog ( tag , ` │ ${ l } ` ) ) ;
if ( lines . length > 5 ) consoleLog ( tag , ` │ ... 共 ${ lines . length } 行 ` ) ;
let timedOut = false ;
if ( options ? . timeout ) {
const timer = setTimeout ( ( ) = > {
timedOut = true ;
child . kill ( ) ;
reject ( new Error ( ` 命令超时 ( ${ options . timeout } ms): ${ desc } ` ) ) ;
} , options . timeout ) ;
child . on ( "close" , ( ) = > clearTimeout ( timer ) ) ;
}
consoleLog ( tag , "✓ 完成" ) ;
return stdout || "" ;
} catch ( err ) {
const e = err as Error & { stderr? : Buffer } ;
const stderr = e . stderr ? . toString ( ) . trim ( ) ;
if ( stderr ) {
stderr . split ( "\n" ) . filter ( Boolean ) . forEach ( ( l ) = > consoleErr ( tag , ` │ ${ l } ` ) ) ;
const handleData = ( data : Buffer , isStderr : boolean ) = > {
const str = data . toString ( ) ;
const lines = str . split ( "\n" ) . filter ( Boolean ) ;
for ( const raw of lines ) {
const line = raw . trimEnd ( ) ;
if ( ! line ) continue ;
if ( isStderr ) {
consoleErr ( tag , ` │ ${ line . slice ( 0 , 2000 ) } ` ) ;
} else {
consoleLog ( tag , ` │ ${ line . slice ( 0 , 2000 ) } ` ) ;
}
consoleErr ( tag , ` 失败: ${ e . message } ` ) ;
throw err ;
options ? . onLine ? . ( line ) ;
}
} ;
child . stdout ? . on ( "data" , ( data : Buffer ) = > handleData ( data , false ) ) ;
child . stderr ? . on ( "data" , ( data : Buffer ) = > handleData ( data , true ) ) ;
child . on ( "close" , ( code ) = > {
if ( timedOut ) return ;
if ( code === 0 ) {
consoleLog ( tag , ` ✓ 完成 ` ) ;
resolve ( ) ;
} else {
consoleErr ( tag , ` 失败 (exit= ${ code } ) ` ) ;
reject ( new Error ( ` Command failed with exit code ${ code } : ${ desc } ` ) ) ;
}
} ) ;
child . on ( "error" , ( err ) = > {
if ( timedOut ) return ;
consoleErr ( tag , ` 异常: ${ err . message } ` ) ;
reject ( err ) ;
} ) ;
} ) ;
}
/**
@@ -130,19 +170,15 @@ function getAuthenticatedRemoteUrl(): string {
/**
* 通过 git ls-remote 获取远程最新 commit hash
*/
export function getRemoteCommitHash ( ) : string | null {
export async function getRemoteCommitHash ( ) : Promise < string | null > {
try {
const url = getAuthenticatedRemoteUrl ( ) ;
const output = execSync ( ` git ls-remote " ${ url } " HEAD ` , {
encoding : "utf-8" ,
let stdout = "" ;
await execAsync ( "git" , [ "ls-remote" , url , "HEAD" ] , "git-ls-remote" , {
timeout : 15000 ,
env : {
. . . process . env ,
GIT_TERMINAL_PROMPT : "0" ,
} ,
windowsHide : true ,
onLine : ( line ) = > { stdout += line + "\n" ; } ,
} ) ;
const match = output . match ( /^([a-f0-9]+)\s+HEAD/m ) ;
const match = stdout . match ( /^([a-f0-9]+)\s+HEAD/m ) ;
return match ? . [ 1 ] || null ;
} catch {
return null ;
@@ -152,19 +188,16 @@ export function getRemoteCommitHash(): string | null {
/**
* 获取远程 .version 文件内容并解析为 VersionFile
*/
export function getRemoteVersionFile ( ) : VersionFile | null {
export async function getRemoteVersionFile ( ) : Promise < VersionFile | null > {
// 尝试方式1: git archive
try {
const url = getAuthenticatedRemoteUrl ( ) ;
const output = execSync (
` git archive --remote=" ${ url } " HEAD:.version 2>nul ` ,
{
encoding : "utf-8" ,
let stdout = "" ;
await execAsync ( "git" , [ "archive" , ` --remote= ${ url } ` , "HEAD:.version" ] , "git-archive" , {
timeout : 15000 ,
windowsHide : true ,
}
) ;
const parsed = JSON . parse ( output . trim ( ) ) as VersionFile ;
onLine : ( line ) = > { stdout += line ; } ,
} ) ;
const parsed = JSON . parse ( stdout . trim ( ) ) as VersionFile ;
if ( parsed . version ) return parsed ;
} catch {
// fall through
@@ -178,18 +211,15 @@ export function getRemoteVersionFile(): VersionFile | null {
* 通过浅克隆获取远程 .version 文件(备选方案)
* OneDev 不支持 git archive --remote,改用完整浅克隆
*/
function getRemoteVersionByClone ( ) : VersionFile | null {
async function getRemoteVersionByClone ( ) : Promise < VersionFile | null > {
const tmpDir = path . join (
process . env . TEMP || "/tmp" ,
` recipe_tool_version_ ${ Date . now ( ) } `
) ;
try {
const url = getAuthenticatedRemoteUrl ( ) ;
execSync ( ` git clone --depth 1 " ${ url } " " ${ tmpDir } " ` , {
encoding : "utf-8" ,
await execAsync ( "git" , [ "clone" , "--depth" , "1" , url , tmpDir ] , "git-clone" , {
timeout : 60000 ,
windowsHide : true ,
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
const versionPath = path . join ( tmpDir , ".version" ) ;
if ( fs . existsSync ( versionPath ) ) {
@@ -234,7 +264,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
const current = readLocalVersionFile ( ) ;
consoleLog ( "检查" , ` 当前版本: v ${ current . version } ( ${ current . release_date || "无日期" } ) ` ) ;
const remoteCommit = getRemoteCommitHash ( ) ;
const remoteCommit = await getRemoteCommitHash ( ) ;
if ( ! remoteCommit ) {
consoleErr ( "检查" , "无法连接到远程仓库" ) ;
@@ -247,7 +277,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
}
consoleLog ( "检查" , ` 远程 HEAD: ${ remoteCommit } ` ) ;
const remoteVersionFile = getRemoteVersionFile ( ) ;
const remoteVersionFile = await getRemoteVersionFile ( ) ;
if ( remoteVersionFile ) {
const cmp = compareVersions ( remoteVersionFile . version , current . version ) ;
@@ -279,10 +309,10 @@ export async function checkForUpdate(): Promise<VersionInfo> {
/**
* 检查 Docker 是否可用
*/
function checkDockerAvailable ( ) : { ok : boolean ; message : string } {
async function checkDockerAvailable ( ) : Promise < { ok : boolean ; message : string } > {
consoleLog ( "Docker" , "检查 Docker 守护进程..." ) ;
try {
execSyncAndLog ( "docker info" , "Docker" ) ;
await execAsync ( "docker" , [ " info"] , "Docker" ) ;
consoleLog ( "Docker" , "✓ Docker 正常" ) ;
return { ok : true , message : "" } ;
} catch {
@@ -294,7 +324,7 @@ function checkDockerAvailable(): { ok: boolean; message: string } {
"更新功能需要在 Docker 容器内运行,当前环境不支持。\n" +
"如需测试,请使用 Docker 部署后访问容器内的页面:\n" +
" docker compose -p recipe_tool up -d\n" +
"然后访问 http://localhost:3000 /settings/update" ,
"然后访问 http://localhost:8011 /settings/update" ,
} ;
}
}
@@ -306,13 +336,23 @@ function checkDockerAvailable(): { ok: boolean; message: string } {
export async function executeUpdate (
onLog : LogCallback = noopLog
) : Promise < { success : boolean ; message : string } > {
const tmpDir = path . join (
process . env . TEMP || "/tmp " ,
` recipe_tool_update_ ${ Date . now ( ) } `
) ;
const cwd = process . cwd ( ) ; // /app(容器内工作目录,含 docker-compose.yml)
const token = process . env . ONEDEV_ACCESS_TOKEN || "" ;
const repoUrl = process . env . REPOSITORY_URL || "https://git.hanhan.ltd/recipe_tool" ;
const registryImage = process . env . REGISTRY_IMAGE || "recipe_tool:latest" ;
// 从 REPOSITORY_URL 提取 registry host 和 project path 以拼出完整镜像地址
// 例如: https://git.hanhan.ltd/recipe_tool → host=git.hanhan.ltd, path=recipe_tool
// 完整镜像: git.hanhan.ltd/recipe_tool/recipe_tool:latest
const repoUrlObj = new URL ( repoUrl ) ;
const registryHost = repoUrlObj . host ;
const projectPath = repoUrlObj . pathname . replace ( /^\/|\/$/g , "" ) ;
const fullImage = ` ${ registryHost } / ${ projectPath } / ${ registryImage } ` ;
consoleLog ( "执行" , "=== 开始执行更新 ===" ) ;
consoleLog ( "执行" , ` 临时 目录: ${ tmpDir } ` ) ;
consoleLog ( "执行" , ` 工作 目录: ${ cwd } , 镜像: ${ fullImage } ` ) ;
const logLine = ( line : string ) = > onLog ( line ) ;
try {
onLog ( "开始更新流程" ) ;
@@ -320,7 +360,7 @@ export async function executeUpdate(
// 0. 检查 Docker 是否可用
onLog ( "[0/4] 检查 Docker 环境" ) ;
consoleLog ( "执行" , "步骤 0/4 — 检查 Docker 环境" ) ;
const dockerCheck = checkDockerAvailable ( ) ;
const dockerCheck = await checkDockerAvailable ( ) ;
if ( ! dockerCheck . ok ) {
onLog ( ` ✗ ${ dockerCheck . message } ` ) ;
consoleErr ( "执行" , "Docker 检查未通过,更新终止" ) ;
@@ -328,18 +368,30 @@ export async function executeUpdate(
}
onLog ( "✓ Docker 环境正常" ) ;
// 1. 拉取最新代码
const url = getAuthenticatedRemoteUrl ( ) ;
onLog ( "[1/4] 克隆最新代码 " ) ;
consoleLog ( "执行" , "步骤 1/4 — 克隆代码" ) ;
execSyncAndLog (
` git clone --depth 1 " ${ url } " " ${ tmpDir } " ` ,
"git-clone" ,
{ timeout : 120000 , stdio : [ "ignore" , "pipe" , "pipe" ] }
// 1. 登录 OneDev 镜像仓库并 拉取最新镜像
onLog ( "[1/4] 登录镜像仓库" ) ;
consoleLog ( "执行" , "步骤 1/4 — 登录 OneDev 镜像仓库 ") ;
if ( token ) {
await execAsync (
"sh" ,
[ "-c" , ` echo " ${ token } " | docker login ${ registryHost } -u access-token --password-stdin ` ] ,
"docker-login" ,
{ timeout : 15000 , onLine : logLine }
) ;
onLog ( "✓ 代码拉取完成" ) ;
} else {
onLog ( "⚠ 未配置 ONEDEV_ACCESS_TOKEN,尝试匿名拉取" ) ;
consoleLog ( "执行" , "未配置 access token,尝试匿名拉取" ) ;
}
// 2. 生成 .env 文件
onLog ( "[1/4] 拉取新镜像" ) ;
consoleLog ( "执行" , "步骤 1/4 — 拉取镜像" ) ;
await execAsync ( "docker" , [ "pull" , fullImage ] , "docker-pull" , {
timeout : 300000 ,
onLine : logLine ,
} ) ;
onLog ( "✓ 镜像拉取完成" ) ;
// 2. 生成 .env 配置文件(供 docker-compose 解析 ${VAR})
onLog ( "[2/4] 准备环境变量" ) ;
consoleLog ( "执行" , "步骤 2/4 — 准备环境变量" ) ;
const envContent = [
@@ -351,31 +403,38 @@ export async function executeUpdate(
` AI_API_KEY= ${ process . env . AI_API_KEY || "" } ` ,
` AI_BASE_URL= ${ process . env . AI_BASE_URL || "https://api.openai.com/v1" } ` ,
` AI_MODEL= ${ process . env . AI_MODEL || "gpt-3.5-turbo" } ` ,
` ONEDEV_ACCESS_TOKEN= ${ process . env . ONEDEV_ACCESS_TOKEN || "" } ` ,
` ONEDEV_ACCESS_TOKEN= ${ token } ` ,
` REGISTRY_IMAGE= ${ registryImage } ` ,
` REPOSITORY_URL= ${ repoUrl } ` ,
` GIT_REMOTE_URL= ${ process . env . GIT_REMOTE_URL || "https://git.hanhan.ltd/recipe_tool.git" } ` ,
] . join ( "\n" ) ;
fs . writeFileSync ( path . join ( tmpDir , ".env" ) , envContent , "utf-8" ) ;
fs . writeFileSync ( path . join ( cwd , ".env" ) , envContent , "utf-8" ) ;
consoleLog ( "执行" , ` .env 已写入 ${ envContent . split ( "\n" ) . length } 行 ` ) ;
onLog ( "✓ 环境变量已准备" ) ;
// 3. 构建新镜像
onLog ( "[3/4] 构建新 Docker 镜像 " ) ;
consoleLog ( "执行" , "步骤 3/4 — 构建镜像(可能耗时较长) " ) ;
execSyncAndLog (
` cd " ${ tmpDir } " && docker-compose -p recipe_tool build ` ,
"docker-build" ,
{ timeout : 600000 , stdio : [ "ignore" , "pipe" , "pipe" ] }
) ;
onLog ( "✓ 镜像构建完成" ) ;
// 3. 停止旧容器(释放端口),忽略失败
onLog ( "[3/4] 停止旧容器 " ) ;
consoleLog ( "执行" , "步骤 3/4 — 停止旧容器 " ) ;
try {
await execAsync ( "docker-compose" , [ "-p" , "recipe_tool" , "down" , "--remove-orphans" ] , "docker-down" , {
cwd ,
timeout : 30000 ,
onLine : logLine ,
} ) ;
} catch {
consoleLog ( "执行" , "旧容器停止(可能不存在,忽略)" ) ;
}
onLog ( "✓ 旧容器已停止" ) ;
// 4. 重启服务
onLog ( "[4/4] 重启服务 " ) ;
consoleLog ( "执行" , "步骤 4/4 — 重启服务 " ) ;
execSyncAndLog (
` cd " ${ tmpDir } " && docker-compose -p recipe_tool up -d ` ,
"docker-up" ,
{ timeout : 60000 , stdio : [ "ignore" , "pipe" , "pipe" ] }
) ;
onLog ( "✓ 服务已重启 " ) ;
// 4. 启动新容器(使用拉取的镜像)
onLog ( "[4/4] 启动新容器 " ) ;
consoleLog ( "执行" , "步骤 4/4 — 启动新容器 " ) ;
await execAsync ( "docker-compose" , [ "-p" , "recipe_tool" , "up" , "-d" ] , "docker-up" , {
cwd ,
timeout : 60000 ,
onLine : logLine ,
} ) ;
onLog ( "✓ 新容器已启动 " ) ;
consoleLog ( "执行" , "=== 更新成功 ===" ) ;
return { success : true , message : "更新完成" } ;
@@ -384,12 +443,5 @@ export async function executeUpdate(
consoleErr ( "执行" , ` === 更新失败: ${ errMsg } === ` ) ;
onLog ( ` ✗ 更新失败: ${ errMsg } ` ) ;
return { success : false , message : errMsg } ;
} finally {
try {
fs . rmSync ( tmpDir , { recursive : true , force : true } ) ;
consoleLog ( "执行" , ` 临时目录已清理: ${ tmpDir } ` ) ;
} catch {
// ignore cleanup errors
}
}
}