mirror of
https://github.com/zc-zhangchen/any-auto-register.git
synced 2026-05-07 15:54:07 +08:00
修改插件和日志提示
This commit is contained in:
@@ -405,6 +405,9 @@ CAMOUFOX_VERSION=135.0.1 CAMOUFOX_RELEASE=beta.24 docker compose build app
|
||||
| grok2api | Grok token 管理、回填、聊天/API 服务 | `https://github.com/chenyme/grok2api.git` |
|
||||
| kiro-account-manager | Kiro 账号管理相关插件 | `https://github.com/hj01857655/kiro-account-manager.git` |
|
||||
|
||||
插件页中的 **“安装最新版 / 更新到最新版”** 会同步仓库最新代码,且已支持 **卸载**(会先停止服务,再删除本地插件目录)。
|
||||
默认按 **最新 semver tag** 更新;你也可以在“设置 → 插件 → 安装/更新策略”切回 **分支 HEAD** 模式。
|
||||
|
||||
如果你后续要改成 `ghproxy`、`gitclone`、企业 Git 镜像或其他代理地址,需要同步修改:
|
||||
|
||||
```text
|
||||
|
||||
@@ -96,6 +96,7 @@ CONFIG_KEYS = [
|
||||
"grok2api_quota",
|
||||
"kiro_manager_path",
|
||||
"kiro_manager_exe",
|
||||
"external_apps_update_mode",
|
||||
"contribution_enabled",
|
||||
"contribution_server_url",
|
||||
"contribution_key",
|
||||
@@ -136,6 +137,8 @@ def get_config():
|
||||
all_cfg["contribution_enabled"] = "0"
|
||||
if not all_cfg.get("contribution_server_url"):
|
||||
all_cfg["contribution_server_url"] = "http://new.xem8k5.top:7317/"
|
||||
if not all_cfg.get("external_apps_update_mode"):
|
||||
all_cfg["external_apps_update_mode"] = "tag"
|
||||
# 只返回已知 key,未设置的返回空字符串
|
||||
return {k: all_cfg.get(k, "") for k in CONFIG_KEYS}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from core.base_platform import Account, AccountStatus
|
||||
from core.db import AccountModel, engine
|
||||
from services.external_apps import install, list_status, start, start_all, stop, stop_all
|
||||
from services.external_apps import install, list_status, start, start_all, stop, stop_all, uninstall
|
||||
from services.chatgpt_sync import backfill_chatgpt_account_to_cpa, get_cliproxy_sync_state
|
||||
|
||||
router = APIRouter(prefix="/integrations", tags=["integrations"])
|
||||
@@ -60,6 +60,11 @@ def install_service(name: str):
|
||||
return install(name)
|
||||
|
||||
|
||||
@router.post("/services/{name}/uninstall")
|
||||
def uninstall_service(name: str):
|
||||
return uninstall(name)
|
||||
|
||||
|
||||
@router.post("/services/{name}/stop")
|
||||
def stop_service(name: str):
|
||||
return stop(name)
|
||||
|
||||
28
api/tasks.py
28
api/tasks.py
@@ -169,9 +169,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
success = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
workspace_success = 0
|
||||
start_gate_lock = threading.Lock()
|
||||
workspace_progress_lock = threading.Lock()
|
||||
next_start_time = time.time()
|
||||
|
||||
def _sleep_with_control(
|
||||
@@ -203,7 +201,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
)
|
||||
|
||||
def _do_one(i: int):
|
||||
nonlocal next_start_time, workspace_success
|
||||
nonlocal next_start_time
|
||||
proxy_pool = None
|
||||
_proxy = None
|
||||
current_email = req.email or ""
|
||||
@@ -295,13 +293,6 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
if _proxy:
|
||||
proxy_pool.report_success(_proxy)
|
||||
_log(task_id, f"[OK] 注册成功: {account.email}")
|
||||
workspace_id = ""
|
||||
if isinstance(account.extra, dict):
|
||||
workspace_id = str(account.extra.get("workspace_id") or "").strip()
|
||||
if workspace_id:
|
||||
with workspace_progress_lock:
|
||||
workspace_success += 1
|
||||
_log(task_id, f"[ChatGPT] workspace进度: {workspace_success}/{req.count}")
|
||||
_save_task_log(req.platform, account.email, "success")
|
||||
_auto_upload_integrations(task_id, saved_account or account)
|
||||
cashier_url = (account.extra or {}).get("cashier_url", "")
|
||||
@@ -358,6 +349,11 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
stopped = True
|
||||
else:
|
||||
errors.append(result.message)
|
||||
_task_store.update_counters(
|
||||
task_id,
|
||||
success=success,
|
||||
registered=success + skipped + len(errors),
|
||||
)
|
||||
if stopped or control.is_stop_requested():
|
||||
stopped = True
|
||||
for pending in futures:
|
||||
@@ -369,6 +365,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
task_id,
|
||||
status="failed",
|
||||
success=success,
|
||||
registered=success + skipped + len(errors),
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
error=str(e),
|
||||
@@ -388,6 +385,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest):
|
||||
task_id,
|
||||
status=final_status,
|
||||
success=success,
|
||||
registered=success + skipped + len(errors),
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -473,11 +471,17 @@ async def stream_logs(task_id: str, since: int = 0):
|
||||
sent = since
|
||||
while True:
|
||||
logs, status = _task_store.log_state(task_id)
|
||||
snapshot = _task_store.snapshot(task_id)
|
||||
counters = {
|
||||
"success": int(snapshot.get("success") or 0),
|
||||
"registered": int(snapshot.get("registered") or 0),
|
||||
"total": int(snapshot.get("total") or 0),
|
||||
}
|
||||
while sent < len(logs):
|
||||
yield f"data: {json.dumps({'line': logs[sent]})}\n\n"
|
||||
yield f"data: {json.dumps({'line': logs[sent], **counters})}\n\n"
|
||||
sent += 1
|
||||
if status in ("done", "failed", "stopped"):
|
||||
yield f"data: {json.dumps({'done': True, 'status': status})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True, 'status': status, **counters})}\n\n"
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ class RegisterTaskRecord:
|
||||
progress: str = "0/0"
|
||||
logs: list[str] = field(default_factory=list)
|
||||
success: int = 0
|
||||
registered: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
cashier_urls: list[str] = field(default_factory=list)
|
||||
@@ -155,9 +156,11 @@ class RegisterTaskRecord:
|
||||
"platform": self.platform,
|
||||
"source": self.source,
|
||||
"meta": dict(self.meta),
|
||||
"total": self.total,
|
||||
"progress": self.progress,
|
||||
"logs": list(self.logs),
|
||||
"success": self.success,
|
||||
"registered": self.registered,
|
||||
"skipped": self.skipped,
|
||||
"errors": list(self.errors),
|
||||
"control": self.control.snapshot(),
|
||||
@@ -265,12 +268,28 @@ class RegisterTaskStore:
|
||||
record.cashier_urls.append(cashier_url)
|
||||
record.updated_at = time.time()
|
||||
|
||||
def update_counters(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
success: int | None = None,
|
||||
registered: int | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
record = self._records[task_id]
|
||||
if success is not None:
|
||||
record.success = max(0, int(success))
|
||||
if registered is not None:
|
||||
record.registered = max(0, int(registered))
|
||||
record.updated_at = time.time()
|
||||
|
||||
def finish(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str,
|
||||
success: int,
|
||||
registered: int | None = None,
|
||||
skipped: int,
|
||||
errors: list[str],
|
||||
error: str = "",
|
||||
@@ -279,6 +298,10 @@ class RegisterTaskStore:
|
||||
record = self._records[task_id]
|
||||
record.status = status
|
||||
record.success = success
|
||||
if registered is None:
|
||||
record.registered = max(success + skipped + len(errors), 0)
|
||||
else:
|
||||
record.registered = max(0, int(registered))
|
||||
record.skipped = skipped
|
||||
record.errors = list(errors)
|
||||
record.error = error
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, message, Space } from 'antd'
|
||||
import { Button, message, Space, Tag } from 'antd'
|
||||
import { CopyOutlined, FastForwardOutlined, StopOutlined } from '@ant-design/icons'
|
||||
|
||||
import { API_BASE, apiFetch, getToken } from '@/lib/utils'
|
||||
@@ -11,8 +11,36 @@ interface TaskLogPanelProps {
|
||||
|
||||
type TaskTerminalStatus = 'idle' | 'done' | 'failed' | 'stopped'
|
||||
|
||||
interface RegisterSummary {
|
||||
success: number
|
||||
registered: number
|
||||
total: number
|
||||
}
|
||||
|
||||
function parseCounter(value: unknown): number {
|
||||
const n = Number(value || 0)
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
function normalizeSummary(next: RegisterSummary): RegisterSummary {
|
||||
const success = parseCounter(next.success)
|
||||
const registered = Math.max(parseCounter(next.registered), success)
|
||||
const total = Math.max(parseCounter(next.total), registered)
|
||||
return { success, registered, total }
|
||||
}
|
||||
|
||||
function mergeSummary(previous: RegisterSummary, incoming: Partial<RegisterSummary>): RegisterSummary {
|
||||
return normalizeSummary({
|
||||
success: incoming.success ?? previous.success,
|
||||
registered: incoming.registered ?? previous.registered,
|
||||
total: incoming.total ?? previous.total,
|
||||
})
|
||||
}
|
||||
|
||||
export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [summary, setSummary] = useState<RegisterSummary>({ success: 0, registered: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [terminalStatus, setTerminalStatus] = useState<TaskTerminalStatus>('idle')
|
||||
const [skipLoading, setSkipLoading] = useState(false)
|
||||
@@ -81,6 +109,7 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
|
||||
const maxRetryMs = 8000
|
||||
nextSinceRef.current = 0
|
||||
setLines([])
|
||||
setSummary({ success: 0, registered: 0, total: 0 })
|
||||
setError('')
|
||||
setTerminalStatus('idle')
|
||||
setStopRequested(false)
|
||||
@@ -93,12 +122,22 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
|
||||
const snapshot = await apiFetch(`/tasks/${taskId}`) as {
|
||||
logs?: string[]
|
||||
status?: TaskTerminalStatus | string
|
||||
success?: number
|
||||
registered?: number
|
||||
total?: number
|
||||
control?: { stop_requested?: boolean }
|
||||
}
|
||||
if (cancelled) return true
|
||||
|
||||
const snapshotLines = Array.isArray(snapshot.logs) ? snapshot.logs : []
|
||||
setLines(snapshotLines)
|
||||
setSummary((previous) =>
|
||||
mergeSummary(previous, {
|
||||
success: snapshot.success,
|
||||
registered: snapshot.registered,
|
||||
total: snapshot.total,
|
||||
}),
|
||||
)
|
||||
nextSinceRef.current = snapshotLines.length
|
||||
setStopRequested(Boolean(snapshot.control?.stop_requested))
|
||||
|
||||
@@ -159,7 +198,17 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
|
||||
line?: string
|
||||
done?: boolean
|
||||
status?: TaskTerminalStatus
|
||||
success?: number
|
||||
registered?: number
|
||||
total?: number
|
||||
}
|
||||
setSummary((previous) =>
|
||||
mergeSummary(previous, {
|
||||
success: payload.success,
|
||||
registered: payload.registered,
|
||||
total: payload.total,
|
||||
}),
|
||||
)
|
||||
if (payload.line) {
|
||||
nextSinceRef.current += 1
|
||||
setLines((previous) => [...previous, payload.line!])
|
||||
@@ -224,6 +273,12 @@ export function TaskLogPanel({ taskId, onDone }: TaskLogPanelProps) {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Space wrap style={{ marginBottom: 8 }}>
|
||||
<Tag color="green">注册成功:{summary.success}</Tag>
|
||||
<Tag color="blue">已注册:{summary.registered}</Tag>
|
||||
<Tag color="default">总共注册:{summary.total}</Tag>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Button
|
||||
|
||||
@@ -71,6 +71,10 @@ const SELECT_FIELDS: Record<string, { label: string; value: string }[]> = {
|
||||
{ label: 'AT(Access Token,推荐)', value: 'at' },
|
||||
{ label: 'RT(Refresh Token)', value: 'rt' },
|
||||
],
|
||||
external_apps_update_mode: [
|
||||
{ label: 'latest semver tag(推荐)', value: 'tag' },
|
||||
{ label: '分支 HEAD', value: 'branch' },
|
||||
],
|
||||
}
|
||||
|
||||
const TAB_ITEMS = [
|
||||
@@ -793,6 +797,7 @@ function IntegrationsPanel() {
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [updateMode, setUpdateMode] = useState<'tag' | 'branch'>('tag')
|
||||
const saved = false
|
||||
const [resultModal, setResultModal] = useState({
|
||||
open: false,
|
||||
@@ -813,8 +818,13 @@ function IntegrationsPanel() {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await apiFetch('/integrations/services')
|
||||
const [d, cfg] = await Promise.all([
|
||||
apiFetch('/integrations/services'),
|
||||
apiFetch('/config'),
|
||||
])
|
||||
setItems(d.items || [])
|
||||
const mode = String(cfg?.external_apps_update_mode || 'tag').trim().toLowerCase()
|
||||
setUpdateMode(mode === 'branch' ? 'branch' : 'tag')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -859,6 +869,22 @@ function IntegrationsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateInstallMode = async (nextMode: 'tag' | 'branch') => {
|
||||
setBusy('update-mode')
|
||||
try {
|
||||
await apiFetch('/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ data: { external_apps_update_mode: nextMode } }),
|
||||
})
|
||||
setUpdateMode(nextMode)
|
||||
message.success(nextMode === 'tag' ? '已切换到 tag 模式' : '已切换到分支模式')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '切换失败')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{false ? (
|
||||
@@ -919,6 +945,24 @@ function IntegrationsPanel() {
|
||||
</pre>
|
||||
</Modal>
|
||||
|
||||
<Card title="安装/更新策略">
|
||||
<Space wrap align="center">
|
||||
<Select
|
||||
style={{ width: 320 }}
|
||||
value={updateMode}
|
||||
options={SELECT_FIELDS.external_apps_update_mode}
|
||||
onChange={(value) => setUpdateMode(value as 'tag' | 'branch')}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy === 'update-mode'}
|
||||
onClick={() => updateInstallMode(updateMode)}
|
||||
>
|
||||
保存策略
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="批量操作">
|
||||
<Space wrap>
|
||||
<Button loading={busy === 'start-all'} onClick={() => doAction('start-all', apiFetch('/integrations/services/start-all', { method: 'POST' }))}>
|
||||
@@ -964,9 +1008,16 @@ function IntegrationsPanel() {
|
||||
loading={busy === `install-${item.name}`}
|
||||
onClick={() => doAction(`install-${item.name}`, apiFetch(`/integrations/services/${item.name}/install`, { method: 'POST' }))}
|
||||
>
|
||||
安装
|
||||
安装最新版
|
||||
</Button>
|
||||
) : null}
|
||||
) : (
|
||||
<Button
|
||||
loading={busy === `install-${item.name}`}
|
||||
onClick={() => doAction(`install-${item.name}`, apiFetch(`/integrations/services/${item.name}/install`, { method: 'POST' }))}
|
||||
>
|
||||
更新到最新版
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
loading={busy === `start-${item.name}`}
|
||||
disabled={!item.repo_exists}
|
||||
@@ -980,6 +1031,21 @@ function IntegrationsPanel() {
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={busy === `uninstall-${item.name}`}
|
||||
disabled={!item.repo_exists}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(`确认卸载 ${item.label}?\n会停止服务并删除本地插件目录。`)
|
||||
if (!ok) return
|
||||
doAction(
|
||||
`uninstall-${item.name}`,
|
||||
apiFetch(`/integrations/services/${item.name}/uninstall`, { method: 'POST' }),
|
||||
)
|
||||
}}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
{item.name === 'grok2api' ? (
|
||||
<Button
|
||||
loading={busy === 'backfill-grok'}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -64,6 +66,7 @@ _PROCS: dict[str, subprocess.Popen] = {}
|
||||
_LOG_FILES: dict[str, Any] = {}
|
||||
_LAST_ERROR: dict[str, str] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_SEMVER_TAG_PATTERN = re.compile(r"^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
def _get_setting(key: str, default: str = "") -> str:
|
||||
@@ -104,25 +107,280 @@ def _open_log(name: str):
|
||||
return f
|
||||
|
||||
|
||||
def _clone_repo_if_missing(name: str):
|
||||
repo = _repo_path(name)
|
||||
if repo.exists():
|
||||
def _make_tree_writable(path: Path):
|
||||
if not path.exists():
|
||||
return
|
||||
repo.parent.mkdir(parents=True, exist_ok=True)
|
||||
for root, dirs, files in os.walk(path):
|
||||
for dirname in dirs:
|
||||
p = Path(root) / dirname
|
||||
try:
|
||||
p.chmod(p.stat().st_mode | stat.S_IWRITE)
|
||||
except Exception:
|
||||
pass
|
||||
for filename in files:
|
||||
p = Path(root) / filename
|
||||
try:
|
||||
p.chmod(p.stat().st_mode | stat.S_IWRITE)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
path.chmod(path.stat().st_mode | stat.S_IWRITE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_processes_touching_path(path: Path):
|
||||
if os.name != "nt":
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$p=$args[0]; "
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { (($_.CommandLine -like ('*' + $p + '*')) -or ($_.ExecutablePath -like ('*' + $p + '*'))) } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
||||
str(path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _external_apps_update_mode() -> str:
|
||||
mode = _get_setting("external_apps_update_mode", "tag").strip().lower()
|
||||
return "branch" if mode == "branch" else "tag"
|
||||
|
||||
|
||||
def _git_has_remote_branch(repo: Path, branch: str) -> bool:
|
||||
if not branch:
|
||||
return False
|
||||
check = subprocess.run(
|
||||
["git", "-C", str(repo), "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
return check.returncode == 0
|
||||
|
||||
|
||||
def _origin_default_branch(repo: Path) -> str:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "-C", str(repo), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
||||
text=True,
|
||||
creationflags=_creationflags(),
|
||||
).strip()
|
||||
if out.startswith("origin/"):
|
||||
branch = out.split("/", 1)[1].strip()
|
||||
if branch:
|
||||
return branch
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for candidate in ("main", "master"):
|
||||
if _git_has_remote_branch(repo, candidate):
|
||||
return candidate
|
||||
return "main"
|
||||
|
||||
|
||||
def _current_local_branch(repo: Path) -> str:
|
||||
try:
|
||||
branch = subprocess.check_output(
|
||||
["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
text=True,
|
||||
creationflags=_creationflags(),
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
return branch if branch and branch != "HEAD" else ""
|
||||
|
||||
|
||||
def _sync_repo_to_branch_head(repo: Path, preferred_branch: str = ""):
|
||||
candidates = []
|
||||
preferred = str(preferred_branch or "").strip()
|
||||
if preferred:
|
||||
candidates.append(preferred)
|
||||
local_branch = _current_local_branch(repo)
|
||||
if local_branch and local_branch not in candidates:
|
||||
candidates.append(local_branch)
|
||||
default_branch = _origin_default_branch(repo)
|
||||
if default_branch and default_branch not in candidates:
|
||||
candidates.append(default_branch)
|
||||
for fallback in ("main", "master"):
|
||||
if fallback not in candidates:
|
||||
candidates.append(fallback)
|
||||
|
||||
for branch in candidates:
|
||||
if not _git_has_remote_branch(repo, branch):
|
||||
continue
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "checkout", "-B", branch, f"origin/{branch}"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "reset", "--hard", f"origin/{branch}"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "clean", "-fd"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
return
|
||||
|
||||
raise RuntimeError(f"未找到可用远端分支(repo={repo})")
|
||||
|
||||
|
||||
def _latest_semver_tag(repo: Path) -> str:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"for-each-ref",
|
||||
"refs/tags",
|
||||
"--sort=-version:refname",
|
||||
"--format=%(refname:strip=2)",
|
||||
],
|
||||
text=True,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
for line in out.splitlines():
|
||||
tag = str(line or "").strip()
|
||||
if not tag:
|
||||
continue
|
||||
if _SEMVER_TAG_PATTERN.fullmatch(tag):
|
||||
return tag
|
||||
return ""
|
||||
|
||||
|
||||
def _sync_repo_to_latest_semver_tag(repo: Path) -> bool:
|
||||
tag = _latest_semver_tag(repo)
|
||||
if not tag:
|
||||
return False
|
||||
subprocess.run(
|
||||
["git", "clone", _REMOTE_URLS[name], str(repo)],
|
||||
["git", "-C", str(repo), "checkout", "--force", tag],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "reset", "--hard", tag],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "clean", "-fd"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _sync_repo_to_latest(name: str):
|
||||
repo = _repo_path(name)
|
||||
repo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not repo.exists():
|
||||
subprocess.run(
|
||||
["git", "clone", _REMOTE_URLS[name], str(repo)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "fetch", "--all", "--tags", "--prune"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
mode = _external_apps_update_mode()
|
||||
if mode == "branch":
|
||||
_sync_repo_to_branch_head(repo)
|
||||
return
|
||||
|
||||
if not _sync_repo_to_latest_semver_tag(repo):
|
||||
_sync_repo_to_branch_head(repo)
|
||||
|
||||
|
||||
def install(name: str) -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
if name not in _SERVICE_META:
|
||||
raise KeyError(name)
|
||||
_clone_repo_if_missing(name)
|
||||
_sync_repo_to_latest(name)
|
||||
return _status_one(name)
|
||||
|
||||
|
||||
def uninstall(name: str) -> dict[str, Any]:
|
||||
if name not in _SERVICE_META:
|
||||
raise KeyError(name)
|
||||
|
||||
try:
|
||||
stop(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _LOCK:
|
||||
repo = _repo_path(name)
|
||||
if repo.exists():
|
||||
_kill_processes_touching_path(repo)
|
||||
last_exc: Exception | None = None
|
||||
for _ in range(12):
|
||||
try:
|
||||
_make_tree_writable(repo)
|
||||
shutil.rmtree(repo)
|
||||
last_exc = None
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
_kill_processes_touching_path(repo)
|
||||
try:
|
||||
subprocess.run(
|
||||
["attrib", "-R", str(repo / "*"), "/S", "/D"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_creationflags(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
if repo.exists():
|
||||
_LAST_ERROR[name] = (
|
||||
f"卸载失败:目录仍存在 {repo}"
|
||||
+ (f",原因:{last_exc}" if last_exc else "")
|
||||
)
|
||||
raise RuntimeError(_LAST_ERROR[name])
|
||||
_PROCS.pop(name, None)
|
||||
_LAST_ERROR.pop(name, None)
|
||||
_close_log(name)
|
||||
|
||||
return _status_one(name)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user