PushGo Auto Commit 2026-03-27T01:00:57.339Z

This commit is contained in:
2026-03-27 09:00:57 +08:00
commit eafa1ffd57
29 changed files with 3213 additions and 0 deletions

158
README.md Normal file
View File

@@ -0,0 +1,158 @@
# PushGo MVP
小白一键把本地项目推送到 GitHub / Gitee / Gitea 的桌面应用Electron + React + TypeScript + Vite
## 功能覆盖
- 启动自动检测 Git 安装状态与授权组件可用性
- 平台选择GitHub / Gitee / Gitea
- 项目文件夹仅支持“点击浏览”选择(已移除拖拽入口,提升跨平台稳定性)
- 仓库 URL 格式与平台匹配校验
- 系统浏览器授权(不使用内嵌网页)
- Gitea 在自建场景下优先按仓库 URL 域名发起登录/授权
- 自动执行 `git init` / `add -A` / `commit` / `remote` / `push`
- 推送默认 `main`,必要时自动回退 `master`
- 成功页支持复制仓库链接和浏览器打开
- 不保存历史记录路径、URL、操作记录不落盘
- 登录 token 仅保留在进程内存(关闭应用即失效)
## 技术栈
- Electron
- React + React Router
- TypeScript
- Vite
- Node `child_process` 调用本机 `git`
## 目录结构
```txt
src/
main/ # Electron 主进程、服务层、IPC
preload/ # 白名单桥接 API
renderer/ # React UI 与流程页面
shared/ # 前后端共享类型与通道常量
docs/
ai-progress.md
ai-handoff.md
git-identity-setup.md
```
Git 身份设置教程:`docs/git-identity-setup.md`
## 本地运行
### 1) 安装依赖
```bash
npm install
```
### 2) 开发启动
```bash
npm run dev
```
### 3) 构建
```bash
npm run build
```
### 4) 打包
```bash
npm run package
```
## OAuth 配置(可选增强)
当前支持两种模式:
- 默认模式(未配置 OAuth env推送时走系统 git 凭据授权链路(如 Git Credential Manager。首次通常会出现登录/授权窗口,后续会复用授权;若在平台端撤销授权会再次触发。
- 增强模式(配置 OAuth env走完整 OAuth 授权码流程并换取 token仅内存保存会话结束清空
说明:
- Gitea 域名优先级:`repoUrl > PUSHGO_GITEA_BASE_URL > https://gitea.com`
- 若目标系统缺失 git 凭据助手,默认模式可能无法完成授权。
如需启用增强模式,请配置:
```bash
# GitHub OAuth App
export PUSHGO_GITHUB_CLIENT_ID=your_client_id
export PUSHGO_GITHUB_CLIENT_SECRET=your_client_secret
# Gitee OAuth App
export PUSHGO_GITEE_CLIENT_ID=your_client_id
export PUSHGO_GITEE_CLIENT_SECRET=your_client_secret
# Gitea OAuth App可用自建域名
export PUSHGO_GITEA_BASE_URL=https://gitea.com
export PUSHGO_GITEA_CLIENT_ID=your_client_id
export PUSHGO_GITEA_CLIENT_SECRET=your_client_secret
# 可选,默认 write:repository
export PUSHGO_GITEA_SCOPE=write:repository
```
授权回调采用本地临时端口(`http://127.0.0.1:{port}/oauth/callback`),请在 OAuth 应用配置中允许本地回调。
## IPC 通道
- `app:check-git`
- `auth:login`
- `auth:logout`
- `auth:status`
- `repo:validate-url`
- `git:select-folder`
- `push:run`
- `app:open-external`
- 事件:`push:progress`
## 错误提示策略
主流程使用统一中文错误对象:
```ts
{ code, message, actionText?, actionType? }
```
覆盖文档要求的核心错误码,包括:
- Git 未安装
- URL 格式错误 / 平台不匹配
- 授权取消 / 授权失败
- 仓库初始化失败
- 无变更提交
- 远端设置失败
- 推送鉴权失败 / 推送被拒绝
- 网络超时
- 未知错误
## 隐私与会话策略
- 不写入 localStorage 的历史数据
- 不落盘保存项目路径/仓库 URL 历史
- token 仅存内存 Map
- BrowserWindow 使用临时 session 分区(关闭应用即清空)
## 已知限制
- 默认模式依赖系统 git 凭据能力,若环境未安装/未启用可能无法完成首授。
- 若远端启用更严格策略(受保护分支、组织 SSO、强制签名推送可能被拒绝。
- 当前为 MVP不包含冲突可视化、分支管理、PR 流程等扩展能力。
## 故障排查(授权弹窗)
- 若你看到的是“终端要求用户名/密码”而不是浏览器授权弹窗,通常不是 `npm run dev` 本身的问题,而是当前 `git` 没有启用 `credential.helper`
- 启动页会自动检测并尽量自动配置弹窗型凭据助手GCM若仍未就绪会在页面顶部给出明确提示和跳转入口。
- 可用以下命令检查:
```bash
git config --get-all credential.helper
```
- 若为空,请安装并启用 Git Credential Manager或在当前系统配置有效的 git 凭据助手)后重试。
- PushGo 在推送时会关闭终端口令提示并强制 GCM 交互模式;打包后无终端窗口时也应走浏览器/系统授权弹窗。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PushGo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/main.tsx"></script>
</body>
</html>

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "pushgo-v1",
"version": "0.1.0",
"private": true,
"description": "PushGo desktop MVP: 小白一键推送项目到 GitHub/Gitee",
"main": "dist-electron/main/main.js",
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:main\" \"npm:dev:electron\"",
"dev:renderer": "vite",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:electron": "wait-on tcp:5173 file:dist-electron/main/main.js && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .",
"build": "npm run build:renderer && npm run build:main",
"build:renderer": "vite build",
"build:main": "tsc -p tsconfig.main.json",
"preview": "vite preview",
"package": "npm run build && electron-builder"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"electron": "^36.0.0",
"electron-builder": "^25.1.8",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"wait-on": "^8.0.1"
},
"build": {
"appId": "com.pushgo.app",
"productName": "PushGo",
"files": [
"dist/**",
"dist-electron/**",
"package.json"
],
"directories": {
"buildResources": "build"
},
"mac": {
"target": "dmg"
},
"win": {
"target": "nsis"
},
"linux": {
"target": "AppImage"
}
}
}

167
src/main/errors.ts Normal file
View File

@@ -0,0 +1,167 @@
import { AppErrorPayload, ErrorCode } from "../shared/contracts";
type ErrorDefinition = {
message: string;
actionText?: string;
actionType?: string;
};
const ERROR_MAP: Record<ErrorCode, ErrorDefinition> = {
APP_GIT_NOT_FOUND: {
message: "未检测到 Git请先安装后再继续",
actionText: "去安装 Git",
actionType: "open_git_download"
},
APP_GIT_HELPER_MISSING: {
message: "已安装 Git但未检测到可用凭据助手首次授权窗口可能无法弹出",
actionText: "安装凭据助手",
actionType: "open_gcm_download"
},
APP_GIT_HELPER_UNSUPPORTED: {
message: "当前 Git 凭据助手不支持授权弹窗,请切换到 Git Credential Manager",
actionText: "查看配置说明",
actionType: "open_gcm_download"
},
APP_FOLDER_INVALID: {
message: "请选择有效的项目文件夹"
},
URL_INVALID_FORMAT: {
message: "仓库地址格式不正确,请检查后重试"
},
URL_PLATFORM_MISMATCH: {
message: "仓库地址与当前平台不一致,是否切换平台?"
},
AUTH_CANCELLED: {
message: "你已取消登录授权,可重新发起"
},
AUTH_FAILED: {
message: "登录失败,请稍后重试"
},
GIT_INIT_FAILED: {
message: "初始化仓库失败,请检查文件夹权限"
},
GIT_COMMIT_NO_CHANGES: {
message: "没有检测到新变更,已尝试直接推送"
},
GIT_REMOTE_SET_FAILED: {
message: "设置远端仓库失败,请检查仓库地址"
},
GIT_PUSH_AUTH_FAILED: {
message: "推送失败:账号无权限或授权已失效"
},
GIT_PUSH_REJECTED: {
message: "推送被拒绝,远端可能有更新,请先同步"
},
NETWORK_TIMEOUT: {
message: "网络超时,请检查网络后重试"
},
UNKNOWN: {
message: "发生未知错误,请重试"
}
};
export class AppError extends Error {
readonly payload: AppErrorPayload;
constructor(code: ErrorCode, overrides?: Partial<AppErrorPayload>) {
const base = ERROR_MAP[code];
super(overrides?.message ?? base.message);
this.name = "AppError";
this.payload = {
code,
message: overrides?.message ?? base.message,
actionText: overrides?.actionText ?? base.actionText,
actionType: overrides?.actionType ?? base.actionType
};
}
}
export function createAppError(code: ErrorCode, overrides?: Partial<AppErrorPayload>): AppError {
return new AppError(code, overrides);
}
export function toAppErrorPayload(error: unknown, fallbackCode: ErrorCode = "UNKNOWN"): AppErrorPayload {
if (error instanceof AppError) {
return error.payload;
}
if (isPayload(error)) {
return error;
}
const base = ERROR_MAP[fallbackCode];
return {
code: fallbackCode,
message: base.message,
actionText: base.actionText,
actionType: base.actionType
};
}
function isPayload(error: unknown): error is AppErrorPayload {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
"message" in error &&
typeof (error as { code: unknown }).code === "string" &&
typeof (error as { message: unknown }).message === "string"
);
}
export function isGitNotFound(output: string): boolean {
const content = output.toLowerCase();
return content.includes("command not found") || content.includes("is not recognized") || content.includes("enoent");
}
export function inferGitPushError(stderr: string): AppError {
const message = stderr.toLowerCase();
if (message.includes("no credential store has been selected") || message.includes("credential.credentialstore")) {
return createAppError("GIT_PUSH_AUTH_FAILED", {
message: "Git Credential Manager 未配置凭据存储。请先配置 credential.credentialStore如 cache 或 secretservice后重试。"
});
}
if (message.includes("can not use the 'cache' credential store on windows")) {
return createAppError("GIT_PUSH_AUTH_FAILED", {
message: "Windows 不支持 cache 凭据存储。请改用 wincredman 后重试。"
});
}
if (message.includes("credential-manager") && message.includes("is not a git command")) {
return createAppError("GIT_PUSH_AUTH_FAILED", {
message: "未找到 Git Credential Manager 可执行程序,请安装并启用后重试。"
});
}
if (
message.includes("user interaction is not allowed") ||
message.includes("cannot prompt because user interactivity has been disabled") ||
message.includes("unable to open browser") ||
message.includes("failed to launch browser")
) {
return createAppError("GIT_PUSH_AUTH_FAILED", {
message: "无法拉起授权窗口。请检查系统默认浏览器与图形会话后重试。"
});
}
if (
message.includes("authentication failed") ||
message.includes("could not read username") ||
message.includes("403") ||
message.includes("access denied")
) {
return createAppError("GIT_PUSH_AUTH_FAILED");
}
if (message.includes("failed to push some refs") || message.includes("non-fast-forward") || message.includes("rejected")) {
return createAppError("GIT_PUSH_REJECTED");
}
if (message.includes("timed out") || message.includes("timeout")) {
return createAppError("NETWORK_TIMEOUT");
}
return createAppError("UNKNOWN");
}

131
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,131 @@
import { dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron";
import { IPC_CHANNELS, PUSH_PROGRESS_CHANNEL } from "../shared/channels";
import { AppErrorPayload, IpcResult, Platform, PushRunParams } from "../shared/contracts";
import { createAppError, toAppErrorPayload } from "./errors";
import { SessionAuthService } from "./services/auth-service";
import { CliGitService } from "./services/git-service";
import { DefaultPushOrchestrator } from "./services/push-orchestrator";
import { DefaultRepoService } from "./services/repo-service";
type Services = {
gitService: CliGitService;
authService: SessionAuthService;
repoService: DefaultRepoService;
pushOrchestrator: DefaultPushOrchestrator;
};
export function registerIpcHandlers(services: Services): void {
ipcMain.handle(IPC_CHANNELS.CHECK_GIT, async () => {
return wrap(async () => {
const installed = await services.gitService.checkInstalled();
if (!installed) {
const notFound = createAppError("APP_GIT_NOT_FOUND");
return {
installed: false,
warning: notFound.payload
};
}
const helperWarning = await services.gitService.checkCredentialHelperReadiness();
return {
installed: true,
warning: helperWarning ?? undefined
};
});
});
ipcMain.handle(IPC_CHANNELS.SELECT_FOLDER, async () => {
return wrap(async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"]
});
return {
path: result.canceled ? null : result.filePaths[0] ?? null
};
});
});
ipcMain.handle(IPC_CHANNELS.VALIDATE_URL, async (_event, payload: { url: string; platform: Platform }) => {
return wrap(async () => {
const validation = services.repoService.validateRepoUrl(payload.url, payload.platform);
const detectedPlatform = services.repoService.detectPlatformFromUrl(payload.url);
return {
...validation,
detectedPlatform
};
});
});
ipcMain.handle(IPC_CHANNELS.AUTH_STATUS, async (_event, payload: { platform: Platform; repoUrl?: string }) => {
return wrap(async () => {
const loggedIn = await services.authService.isLoggedIn(payload.platform, { repoUrl: payload.repoUrl });
return { loggedIn };
});
});
ipcMain.handle(IPC_CHANNELS.AUTH_LOGIN, async (_event, payload: { platform: Platform; repoUrl?: string }) => {
return wrap(async () => {
await services.authService.login(payload.platform, { repoUrl: payload.repoUrl });
return { loggedIn: true };
});
});
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (_event, payload: { platform: Platform; repoUrl?: string }) => {
return wrap(async () => {
await services.authService.logout(payload.platform, { repoUrl: payload.repoUrl });
return { loggedIn: false };
});
});
ipcMain.handle(IPC_CHANNELS.PUSH_RUN, async (event: IpcMainInvokeEvent, payload: PushRunParams) => {
return wrap(async () => {
validatePushParams(payload);
await services.pushOrchestrator.runWithProgress(payload, (progress) => {
event.sender.send(PUSH_PROGRESS_CHANNEL, progress);
});
return {
meta: services.pushOrchestrator.consumeLastMeta()
};
});
});
ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event, payload: { url: string }) => {
return wrap(async () => {
if (!payload?.url) {
throw createAppError("UNKNOWN");
}
await shell.openExternal(payload.url);
return { opened: true };
});
});
}
function validatePushParams(payload: PushRunParams): void {
if (!payload.projectPath) {
throw createAppError("APP_FOLDER_INVALID");
}
if (!payload.repoUrl) {
throw createAppError("URL_INVALID_FORMAT");
}
}
async function wrap<T>(run: () => Promise<T>): Promise<IpcResult<T>> {
try {
const data = await run();
return { ok: true, data };
} catch (error) {
console.error("[PushGo][IPC] handler error:", error);
const normalized: AppErrorPayload = toAppErrorPayload(error);
return {
ok: false,
error: normalized
};
}
}

102
src/main/main.ts Normal file
View File

@@ -0,0 +1,102 @@
import path from "node:path";
import { app, BrowserWindow } from "electron";
import { registerIpcHandlers } from "./ipc";
import { SessionAuthService } from "./services/auth-service";
import { CliGitService } from "./services/git-service";
import { DefaultPushOrchestrator } from "./services/push-orchestrator";
import { DefaultRepoService } from "./services/repo-service";
let mainWindow: BrowserWindow | null = null;
let ipcRegistered = false;
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 980,
minHeight: 680,
show: false,
backgroundColor: "#081321",
title: "PushGo",
webPreferences: {
preload: path.join(__dirname, "../preload/preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
partition: "temp:pushgo-session"
}
});
void loadRenderer(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow?.show();
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
async function loadRenderer(target: BrowserWindow): Promise<void> {
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
target.webContents.on("did-fail-load", async (_event, errorCode, errorDescription, validatedUrl) => {
const html = `
<html lang="zh-CN">
<body style="font-family: sans-serif; padding: 24px;">
<h2>PushGo 页面加载失败</h2>
<p>错误码: ${errorCode}</p>
<p>原因: ${errorDescription}</p>
<p>地址: ${validatedUrl}</p>
</body>
</html>
`;
await target.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
});
if (!app.isPackaged && devServerUrl) {
await target.loadURL(devServerUrl);
return;
}
const rendererEntry = path.join(app.getAppPath(), "dist", "index.html");
await target.loadFile(rendererEntry);
}
function registerIpcIfNeeded(): void {
if (ipcRegistered) {
return;
}
const gitService = new CliGitService();
const authService = new SessionAuthService();
const repoService = new DefaultRepoService();
const pushOrchestrator = new DefaultPushOrchestrator(gitService, authService);
registerIpcHandlers({
gitService,
authService,
repoService,
pushOrchestrator
});
ipcRegistered = true;
}
app.whenReady().then(() => {
registerIpcIfNeeded();
createMainWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});

View File

@@ -0,0 +1,332 @@
import http from "node:http";
import { randomBytes } from "node:crypto";
import { AddressInfo } from "node:net";
import { shell } from "electron";
import { AuthService, Platform } from "../../shared/contracts";
import { createAppError } from "../errors";
const CALLBACK_PATH = "/oauth/callback";
const AUTH_TIMEOUT_MS = 120_000;
const TOKEN_TIMEOUT_MS = 20_000;
const DEFAULT_GITEA_BASE_URL = "https://gitea.com";
const DEFAULT_GITEA_SCOPE = "write:repository";
type OAuthConfig = {
authorizeUrl: string;
tokenUrl: string;
scope: string;
clientIdEnv: string;
clientSecretEnv: string;
};
type AuthOptions = {
repoUrl?: string;
};
const OAUTH_CONFIG_BASE: Record<Exclude<Platform, "gitea">, OAuthConfig> = {
github: {
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scope: "repo",
clientIdEnv: "PUSHGO_GITHUB_CLIENT_ID",
clientSecretEnv: "PUSHGO_GITHUB_CLIENT_SECRET"
},
gitee: {
authorizeUrl: "https://gitee.com/oauth/authorize",
tokenUrl: "https://gitee.com/oauth/token",
scope: "projects",
clientIdEnv: "PUSHGO_GITEE_CLIENT_ID",
clientSecretEnv: "PUSHGO_GITEE_CLIENT_SECRET"
}
};
type AuthCodeResult = {
code: string;
redirectUri: string;
};
export class SessionAuthService implements AuthService {
private readonly tokenStore = new Map<string, string>();
private readonly browserLoginStore = new Set<string>();
async isLoggedIn(platform: Platform, options?: AuthOptions): Promise<boolean> {
const sessionKey = this.resolveSessionKey(platform, options?.repoUrl);
return this.tokenStore.has(sessionKey) || this.browserLoginStore.has(sessionKey);
}
async login(platform: Platform, options?: AuthOptions): Promise<void> {
const sessionKey = this.resolveSessionKey(platform, options?.repoUrl);
if (!this.hasOAuthCredentials(platform)) {
// 默认模式:由 git push 触发系统凭据授权链路(如 GCM
this.browserLoginStore.add(sessionKey);
return;
}
const config = this.resolveConfig(platform, options?.repoUrl);
const state = randomBytes(16).toString("hex");
const { code, redirectUri } = await this.requestAuthorizationCode(platform, config, state);
const token = await this.exchangeToken(config, code, redirectUri);
if (!token) {
throw createAppError("AUTH_FAILED");
}
this.tokenStore.set(sessionKey, token);
this.browserLoginStore.delete(sessionKey);
}
async logout(platform: Platform, options?: AuthOptions): Promise<void> {
const sessionKey = this.resolveSessionKey(platform, options?.repoUrl);
this.tokenStore.delete(sessionKey);
this.browserLoginStore.delete(sessionKey);
}
getToken(platform: Platform, options?: AuthOptions): string | null {
const sessionKey = this.resolveSessionKey(platform, options?.repoUrl);
return this.tokenStore.get(sessionKey) ?? null;
}
private resolveSessionKey(platform: Platform, repoUrl?: string): string {
if (platform !== "gitea") {
return platform;
}
const origin = this.resolveGiteaBaseUrl(repoUrl).toLowerCase();
return `gitea:${origin}`;
}
private hasOAuthCredentials(platform: Platform): boolean {
const base = this.getOAuthConfig(platform);
const clientId = process.env[base.clientIdEnv]?.trim();
const clientSecret = process.env[base.clientSecretEnv]?.trim();
return Boolean(clientId && clientSecret);
}
private resolveConfig(platform: Platform, repoUrl?: string): OAuthConfig & { clientId: string; clientSecret: string } {
const base = this.getOAuthConfig(platform, repoUrl);
const clientId = process.env[base.clientIdEnv]?.trim();
const clientSecret = process.env[base.clientSecretEnv]?.trim();
if (!clientId || !clientSecret) {
throw createAppError("AUTH_FAILED");
}
return {
...base,
clientId,
clientSecret
};
}
private getOAuthConfig(platform: Platform, repoUrl?: string): OAuthConfig {
if (platform !== "gitea") {
return OAUTH_CONFIG_BASE[platform];
}
const giteaBaseUrl = this.resolveGiteaBaseUrl(repoUrl);
return {
authorizeUrl: `${giteaBaseUrl}/login/oauth/authorize`,
tokenUrl: `${giteaBaseUrl}/login/oauth/access_token`,
scope: (process.env.PUSHGO_GITEA_SCOPE ?? DEFAULT_GITEA_SCOPE).trim(),
clientIdEnv: "PUSHGO_GITEA_CLIENT_ID",
clientSecretEnv: "PUSHGO_GITEA_CLIENT_SECRET"
};
}
private resolveGiteaBaseUrl(repoUrl?: string): string {
const repoOrigin = this.resolveOriginFromRepoUrl(repoUrl);
if (repoOrigin) {
return repoOrigin;
}
const raw = (process.env.PUSHGO_GITEA_BASE_URL ?? DEFAULT_GITEA_BASE_URL).trim();
const withProtocol = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`;
try {
const parsed = new URL(withProtocol);
return parsed.origin;
} catch {
return DEFAULT_GITEA_BASE_URL;
}
}
private resolveOriginFromRepoUrl(repoUrl?: string): string | null {
if (!repoUrl) {
return null;
}
try {
const parsed = new URL(repoUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.origin;
} catch {
return null;
}
}
private async requestAuthorizationCode(
platform: Platform,
config: OAuthConfig & { clientId: string; clientSecret: string },
state: string
): Promise<AuthCodeResult> {
return new Promise((resolve, reject) => {
const server = http.createServer();
let settled = false;
const cleanup = () => {
if (server.listening) {
server.close();
}
};
const settle = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
cleanup();
fn();
};
const timeout = setTimeout(() => {
settle(() => reject(createAppError("AUTH_CANCELLED")));
}, AUTH_TIMEOUT_MS);
server.on("request", (req, res) => {
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
if (requestUrl.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
return;
}
const code = requestUrl.searchParams.get("code");
const returnedState = requestUrl.searchParams.get("state");
const oauthError = requestUrl.searchParams.get("error");
if (oauthError === "access_denied") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h2>已取消授权</h2><p>你可以返回 PushGo 重新发起登录。</p>");
settle(() => reject(createAppError("AUTH_CANCELLED")));
return;
}
if (!code || returnedState !== state) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h2>授权失败</h2><p>请返回 PushGo 重新尝试。</p>");
settle(() => reject(createAppError("AUTH_FAILED")));
return;
}
const address = server.address() as AddressInfo;
const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h2>授权成功</h2><p>请返回 PushGo 继续推送。</p>");
settle(() => resolve({ code, redirectUri }));
});
server.on("error", () => {
settle(() => reject(createAppError("AUTH_FAILED")));
});
server.listen(0, "127.0.0.1", async () => {
try {
const address = server.address() as AddressInfo;
const redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
const authUrl = this.buildAuthorizationUrl(platform, config, state, redirectUri);
await shell.openExternal(authUrl);
} catch {
settle(() => reject(createAppError("AUTH_FAILED")));
}
});
});
}
private buildAuthorizationUrl(
platform: Platform,
config: OAuthConfig & { clientId: string; clientSecret: string },
state: string,
redirectUri: string
): string {
const url = new URL(config.authorizeUrl);
url.searchParams.set("client_id", config.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("state", state);
if (config.scope) {
url.searchParams.set("scope", config.scope);
}
if (platform === "gitee") {
url.searchParams.set("force_verify", "true");
}
return url.toString();
}
private async exchangeToken(
config: OAuthConfig & { clientId: string; clientSecret: string },
code: string,
redirectUri: string
): Promise<string> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
try {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body,
signal: controller.signal
});
const data = (await response.json()) as {
access_token?: string;
error?: string;
};
if (data.error === "access_denied") {
throw createAppError("AUTH_CANCELLED");
}
if (!response.ok || !data.access_token) {
throw createAppError("AUTH_FAILED");
}
return data.access_token;
} catch (error) {
if ((error as { name?: string }).name === "AbortError") {
throw createAppError("NETWORK_TIMEOUT");
}
if (error instanceof Error && error.name === "AppError") {
throw error;
}
throw createAppError("AUTH_FAILED");
} finally {
clearTimeout(timeout);
}
}
}

View File

@@ -0,0 +1,611 @@
import { Buffer } from "node:buffer";
import { stat } from "node:fs/promises";
import { AppErrorPayload, GitService, Platform, PushRunMeta } from "../../shared/contracts";
import { createAppError, inferGitPushError, isGitNotFound } from "../errors";
import { CommandExecutionError, runCommand } from "../utils/run-command";
type AuthContext = {
platform: Platform;
token: string | null;
};
export class CliGitService implements GitService {
private authContext: AuthContext | null = null;
private pushMeta: PushRunMeta = {
commitStatus: "committed",
fallbackToMaster: false
};
async checkInstalled(): Promise<boolean> {
try {
await runCommand("git", ["--version"], { timeoutMs: 10_000 });
return true;
} catch (error) {
if (error instanceof CommandExecutionError) {
const output = `${error.stdout}\n${error.stderr}\n${error.message}`;
if (isGitNotFound(output)) {
return false;
}
}
return false;
}
}
async checkCredentialHelperReadiness(): Promise<AppErrorPayload | null> {
const helpers = await this.readCredentialHelpers();
if (this.hasPopupCapableHelper(helpers)) {
return null;
}
const autoHelper = await this.detectAutoCredentialHelper();
if (autoHelper) {
const configured = await this.trySetGlobalCredentialHelper(autoHelper);
if (configured) {
return null;
}
return createAppError("APP_GIT_HELPER_MISSING", {
message: "检测到凭据助手组件,但自动配置失败。请手动启用 Git Credential Manager 后重试。"
}).payload;
}
if (helpers.length > 0) {
return createAppError("APP_GIT_HELPER_UNSUPPORTED", {
message: `当前凭据助手不支持授权弹窗(${helpers.join(", ")})。请切换到 Git Credential Manager。`
}).payload;
}
return createAppError("APP_GIT_HELPER_MISSING").payload;
}
async isRepo(projectPath: string): Promise<boolean> {
await this.ensureProjectDir(projectPath);
try {
const result = await runCommand("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: projectPath,
timeoutMs: 15_000
});
return result.stdout.trim() === "true";
} catch {
return false;
}
}
async initRepo(projectPath: string): Promise<void> {
await this.ensureProjectDir(projectPath);
try {
await runCommand("git", ["init"], { cwd: projectPath, timeoutMs: 15_000 });
} catch {
throw createAppError("GIT_INIT_FAILED");
}
}
async ensureUserConfig(projectPath: string): Promise<void> {
await this.ensureProjectDir(projectPath);
const name = await this.readGitConfig(projectPath, "user.name");
const email = await this.readGitConfig(projectPath, "user.email");
if (!name) {
await runCommand("git", ["config", "user.name", "PushGo User"], {
cwd: projectPath,
timeoutMs: 10_000
});
}
if (!email) {
await runCommand("git", ["config", "user.email", "pushgo@local.invalid"], {
cwd: projectPath,
timeoutMs: 10_000
});
}
}
async addAll(projectPath: string): Promise<void> {
await this.ensureProjectDir(projectPath);
await runCommand("git", ["add", "-A"], { cwd: projectPath, timeoutMs: 20_000 });
}
async commit(projectPath: string, message: string): Promise<"committed" | "no_changes"> {
await this.ensureProjectDir(projectPath);
const hasChanges = await this.hasStagedChanges(projectPath);
if (!hasChanges) {
this.pushMeta.commitStatus = "no_changes";
return "no_changes";
}
try {
await runCommand("git", ["commit", "-m", message], { cwd: projectPath, timeoutMs: 20_000 });
this.pushMeta.commitStatus = "committed";
return "committed";
} catch (error) {
if (error instanceof CommandExecutionError) {
const detail = `${error.stdout}\n${error.stderr}`.toLowerCase();
if (
detail.includes("nothing to commit") ||
detail.includes("no changes added") ||
detail.includes("working tree clean")
) {
this.pushMeta.commitStatus = "no_changes";
return "no_changes";
}
const compact = this.compactDetail(this.composeCommandErrorDetail(error));
if (compact) {
throw createAppError("UNKNOWN", {
message: `提交变更失败(原始信息:${compact}`
});
}
}
throw createAppError("UNKNOWN");
}
}
async setRemote(projectPath: string, repoUrl: string): Promise<void> {
await this.ensureProjectDir(projectPath);
try {
const remotes = await runCommand("git", ["remote"], { cwd: projectPath, timeoutMs: 10_000 });
const hasOrigin = remotes.stdout
.split("\n")
.map((item) => item.trim())
.includes("origin");
if (hasOrigin) {
await runCommand("git", ["remote", "set-url", "origin", repoUrl], {
cwd: projectPath,
timeoutMs: 10_000
});
} else {
await runCommand("git", ["remote", "add", "origin", repoUrl], {
cwd: projectPath,
timeoutMs: 10_000
});
}
} catch {
throw createAppError("GIT_REMOTE_SET_FAILED");
}
}
async push(projectPath: string): Promise<void> {
await this.ensureProjectDir(projectPath);
this.pushMeta.fallbackToMaster = false;
const helpers = await this.ensureCredentialHelperReady(projectPath);
await this.ensureCredentialStoreReady(projectPath, helpers);
const authArgs = this.buildAuthArgs();
const pushEnv = this.buildPushEnv();
await this.primeRemoteAuth(projectPath, pushEnv);
try {
await runCommand("git", [...authArgs, "push", "-u", "origin", "HEAD:main"], {
cwd: projectPath,
timeoutMs: 120_000,
env: pushEnv
});
return;
} catch (error) {
if (this.shouldFallbackToMaster(error)) {
try {
await runCommand("git", [...authArgs, "push", "-u", "origin", "HEAD:master"], {
cwd: projectPath,
timeoutMs: 120_000,
env: pushEnv
});
this.pushMeta.fallbackToMaster = true;
return;
} catch (masterError) {
throw this.mapPushError(masterError);
}
}
throw this.mapPushError(error);
}
}
setAuthContext(platform: Platform, token: string | null): void {
this.authContext = { platform, token };
}
clearAuthContext(): void {
this.authContext = null;
}
consumePushMeta(): PushRunMeta {
const snapshot = { ...this.pushMeta };
this.pushMeta = {
commitStatus: "committed",
fallbackToMaster: false
};
return snapshot;
}
private async ensureProjectDir(projectPath: string): Promise<void> {
if (!projectPath) {
throw createAppError("APP_FOLDER_INVALID");
}
try {
const info = await stat(projectPath);
if (!info.isDirectory()) {
throw createAppError("APP_FOLDER_INVALID");
}
} catch {
throw createAppError("APP_FOLDER_INVALID");
}
}
private async readGitConfig(projectPath: string, key: string): Promise<string | null> {
try {
const result = await runCommand("git", ["config", "--get", key], {
cwd: projectPath,
timeoutMs: 10_000
});
const value = result.stdout.trim();
return value || null;
} catch {
return null;
}
}
private async hasStagedChanges(projectPath: string): Promise<boolean> {
try {
await runCommand("git", ["diff", "--cached", "--quiet"], {
cwd: projectPath,
timeoutMs: 10_000
});
return false;
} catch (error) {
if (error instanceof CommandExecutionError && this.isExitCode(error.code, 1)) {
return true;
}
throw createAppError("UNKNOWN", {
message: "检查变更状态失败,请重试"
});
}
}
private buildAuthArgs(): string[] {
if (!this.authContext?.token) {
return [];
}
const username = this.authContext.platform === "github" ? "x-access-token" : "oauth2";
const credential = Buffer.from(`${username}:${this.authContext.token}`).toString("base64");
const header = `http.extraheader=AUTHORIZATION: Basic ${credential}`;
return ["-c", header];
}
private buildPushEnv(): NodeJS.ProcessEnv {
return {
...process.env,
// 禁止回落到终端用户名/密码输入,避免出现“卡在命令行提示”的体验。
GIT_TERMINAL_PROMPT: "0",
// GUI 进程中强制允许 GCM 交互,避免被判定为非交互模式。
GCM_INTERACTIVE: "always"
};
}
private async ensureCredentialHelperReady(projectPath: string): Promise<string[]> {
if (this.authContext?.token) {
return [];
}
const helpers = await this.readCredentialHelpers(projectPath);
if (this.hasPopupCapableHelper(helpers)) {
return helpers;
}
const autoHelper = await this.detectAutoCredentialHelper();
if (autoHelper) {
await this.setCredentialHelper(projectPath, autoHelper);
return [autoHelper];
}
if (helpers.length > 0) {
throw createAppError("GIT_PUSH_AUTH_FAILED", {
message: `当前凭据助手不支持浏览器授权弹窗(${helpers.join(", ")})。请切换到 Git Credential Manager 后重试。`
});
}
throw createAppError("GIT_PUSH_AUTH_FAILED", {
message: "未检测到 Git 凭据助手,无法弹出授权窗口。请先安装并启用 Git Credential Manager 后重试。"
});
}
private async readCredentialHelpers(projectPath?: string): Promise<string[]> {
try {
const result = await runCommand("git", ["config", "--get-all", "credential.helper"], {
cwd: projectPath,
timeoutMs: 10_000
});
return result.stdout
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
} catch {
return [];
}
}
private async detectAutoCredentialHelper(): Promise<string | null> {
if (await this.canRunGitCredentialManager("credential-manager")) {
return "manager";
}
if (await this.canRunGitCredentialManager("credential-manager-core")) {
return "manager-core";
}
return null;
}
private async canRunGitCredentialManager(command: "credential-manager" | "credential-manager-core"): Promise<boolean> {
if (await this.canRunCommand("git", [command, "--version"])) {
return true;
}
const helperCommand = command === "credential-manager" ? "git-credential-manager" : "git-credential-manager-core";
for (const candidate of this.commandCandidates(helperCommand)) {
if (await this.canRunCommand(candidate, ["--version"])) {
return true;
}
}
return false;
}
private async canRunCommand(command: string, args: string[]): Promise<boolean> {
try {
await runCommand(command, args, {
timeoutMs: 10_000
});
return true;
} catch {
return false;
}
}
private commandCandidates(base: string): string[] {
if (process.platform === "win32") {
return [base, `${base}.exe`];
}
return [base];
}
private hasPopupCapableHelper(helpers: string[]): boolean {
return helpers.some((helper) => /(manager|credential-manager|oauth)/i.test(helper));
}
private async setCredentialHelper(projectPath: string, helper: string): Promise<void> {
try {
await runCommand("git", ["config", "credential.helper", helper], {
cwd: projectPath,
timeoutMs: 10_000
});
} catch {
throw createAppError("GIT_PUSH_AUTH_FAILED", {
message: "自动配置 Git 凭据助手失败,请手动安装或启用 Git Credential Manager 后重试。"
});
}
}
private async ensureCredentialStoreReady(projectPath: string, helpers: string[]): Promise<void> {
if (this.authContext?.token) {
return;
}
if (!this.usesGcmHelper(helpers)) {
return;
}
const envStore = process.env.GCM_CREDENTIAL_STORE?.trim();
if (envStore && !this.isCredentialStoreSupported(envStore)) {
throw createAppError("GIT_PUSH_AUTH_FAILED", {
message: `当前环境变量 GCM_CREDENTIAL_STORE=${envStore} 与当前系统不兼容,请移除或改为 ${this.preferredCredentialStore()} 后重试。`
});
}
const configuredStore = await this.readConfiguredCredentialStore(projectPath);
if (configuredStore && this.isCredentialStoreSupported(configuredStore)) {
return;
}
const configured = await this.trySetCredentialStore(projectPath, this.preferredCredentialStore());
if (!configured) {
throw createAppError("GIT_PUSH_AUTH_FAILED", {
message: "检测到 Git Credential Manager但凭据存储未配置且自动修复失败。请配置 credential.credentialStore 后重试。"
});
}
}
private usesGcmHelper(helpers: string[]): boolean {
return helpers.some((helper) => /(manager|credential-manager)/i.test(helper));
}
private isCredentialStoreSupported(store: string): boolean {
const normalized = store.trim().toLowerCase();
if (!normalized) {
return false;
}
if (process.platform === "win32" && normalized === "cache") {
return false;
}
return true;
}
private preferredCredentialStore(): string {
if (process.platform === "win32") {
return "wincredman";
}
if (process.platform === "darwin") {
return "keychain";
}
return "cache";
}
private async readConfiguredCredentialStore(projectPath: string): Promise<string | null> {
const localStore = await this.readGitConfig(projectPath, "credential.credentialStore");
if (localStore) {
return localStore;
}
return this.readGlobalGitConfig("credential.credentialStore");
}
private async readGlobalGitConfig(key: string): Promise<string | null> {
try {
const result = await runCommand("git", ["config", "--global", "--get", key], {
timeoutMs: 10_000
});
const value = result.stdout.trim();
return value || null;
} catch {
return null;
}
}
private async trySetCredentialStore(projectPath: string, store: string): Promise<boolean> {
try {
await runCommand("git", ["config", "credential.credentialStore", store], {
cwd: projectPath,
timeoutMs: 10_000
});
return true;
} catch {
return false;
}
}
private async primeRemoteAuth(projectPath: string, env: NodeJS.ProcessEnv): Promise<void> {
if (this.authContext?.token) {
return;
}
try {
// 在正式 push 前预热一次远端鉴权,尽量提前触发凭据助手弹窗。
await runCommand("git", ["ls-remote", "--heads", "origin"], {
cwd: projectPath,
timeoutMs: 60_000,
env
});
} catch (error) {
throw this.mapPushError(error);
}
}
private async trySetGlobalCredentialHelper(helper: string): Promise<boolean> {
try {
await runCommand("git", ["config", "--global", "credential.helper", helper], {
timeoutMs: 10_000
});
return true;
} catch {
return false;
}
}
private shouldFallbackToMaster(error: unknown): boolean {
if (!(error instanceof CommandExecutionError)) {
return false;
}
const detail = `${error.stdout}\n${error.stderr}`.toLowerCase();
if (
detail.includes("authentication failed") ||
detail.includes("could not read username") ||
detail.includes("access denied") ||
detail.includes("non-fast-forward") ||
detail.includes("failed to push some refs")
) {
return false;
}
return detail.includes("main") || detail.includes("refs/heads/main") || detail.includes("src refspec main");
}
private mapPushError(error: unknown) {
if (error instanceof CommandExecutionError) {
const detail = this.composeCommandErrorDetail(error);
const mapped = inferGitPushError(detail);
const compact = this.compactDetail(detail);
if (compact) {
console.error(`[PushGo][GitService][push][${mapped.payload.code}]`, compact);
}
if (mapped.payload.code !== "UNKNOWN") {
if (compact) {
return {
...mapped.payload,
message: `${mapped.payload.message}(原始信息:${compact}`
};
}
return mapped;
}
if (compact) {
console.error("[PushGo][GitService][push] unknown command error:", compact);
return createAppError("UNKNOWN", {
message: `推送失败(原始信息:${compact}`
});
}
return mapped;
}
console.error("[PushGo][GitService][push] unknown non-command error:", error);
return createAppError("UNKNOWN");
}
private compactDetail(detail: string): string {
return detail
.replace(/\s+/g, " ")
.trim()
.slice(0, 220);
}
private isExitCode(code: number | string | null, expected: number): boolean {
if (typeof code === "number") {
return code === expected;
}
if (typeof code === "string") {
const parsed = Number.parseInt(code, 10);
return Number.isFinite(parsed) && parsed === expected;
}
return false;
}
private composeCommandErrorDetail(error: CommandExecutionError): string {
const raw = [error.stderr, error.stdout, error.message]
.filter(Boolean)
.join("\n");
return this.redactSensitive(raw);
}
private redactSensitive(content: string): string {
return content
.replace(/(authorization:\s*basic\s+)[a-z0-9+/=]+/gi, "$1[REDACTED]")
.replace(/(https?:\/\/[^:\s/]+:)[^@\s]+@/gi, "$1[REDACTED]@");
}
}

View File

@@ -0,0 +1,76 @@
import { PushOrchestrator, PushProgressEvent, PushRunMeta, PushRunParams } from "../../shared/contracts";
import { createAppError } from "../errors";
import { SessionAuthService } from "./auth-service";
import { CliGitService } from "./git-service";
type ProgressReporter = (event: PushProgressEvent) => void;
export class DefaultPushOrchestrator implements PushOrchestrator {
private lastMeta: PushRunMeta = {
commitStatus: "committed",
fallbackToMaster: false
};
constructor(
private readonly gitService: CliGitService,
private readonly authService: SessionAuthService
) {}
async run(params: PushRunParams): Promise<void> {
await this.execute(params);
}
async runWithProgress(params: PushRunParams, onProgress?: ProgressReporter): Promise<void> {
await this.execute(params, onProgress);
}
consumeLastMeta(): PushRunMeta {
const snapshot = { ...this.lastMeta };
this.lastMeta = {
commitStatus: "committed",
fallbackToMaster: false
};
return snapshot;
}
private async execute(params: PushRunParams, onProgress?: ProgressReporter): Promise<void> {
if (!params.projectPath) {
throw createAppError("APP_FOLDER_INVALID");
}
this.lastMeta = {
commitStatus: "committed",
fallbackToMaster: false
};
const token = this.authService.getToken(params.platform, { repoUrl: params.repoUrl });
this.gitService.setAuthContext(params.platform, token);
try {
onProgress?.({ stage: "prepare", message: "准备仓库" });
const isRepo = await this.gitService.isRepo(params.projectPath);
if (!isRepo) {
await this.gitService.initRepo(params.projectPath);
}
await this.gitService.ensureUserConfig(params.projectPath);
onProgress?.({ stage: "collect", message: "整理文件" });
await this.gitService.addAll(params.projectPath);
onProgress?.({ stage: "commit", message: "提交变更" });
const commitStatus = await this.gitService.commit(params.projectPath, `PushGo Auto Commit ${new Date().toISOString()}`);
onProgress?.({ stage: "upload", message: "上传到远端" });
await this.gitService.setRemote(params.projectPath, params.repoUrl);
await this.gitService.push(params.projectPath);
const pushMeta = this.gitService.consumePushMeta();
this.lastMeta = {
commitStatus,
fallbackToMaster: pushMeta.fallbackToMaster
};
} finally {
this.gitService.clearAuthContext();
}
}
}

View File

@@ -0,0 +1,89 @@
import { Platform, RepoService } from "../../shared/contracts";
import { createAppError } from "../errors";
const DEFAULT_GITEA_BASE_URL = "https://gitea.com";
export class DefaultRepoService implements RepoService {
detectPlatformFromUrl(url: string): Platform | null {
try {
const parsed = new URL(url.trim());
const host = parsed.hostname.toLowerCase();
const pathParts = parsed.pathname.split("/").filter(Boolean);
if (host === "github.com" || host.endsWith(".github.com")) {
return "github";
}
if (host === "gitee.com" || host.endsWith(".gitee.com")) {
return "gitee";
}
const giteaHost = this.getConfiguredGiteaHost();
if (host === giteaHost || host.endsWith(`.${giteaHost}`) || host.includes("gitea")) {
return "gitea";
}
// 对自建域名仓库,默认按 Gitea 识别,保证可继续流程。
if (pathParts.length >= 2) {
return "gitea";
}
return null;
} catch {
return null;
}
}
validateRepoUrl(url: string, platform: Platform): { ok: boolean; code?: string; message?: string } {
const raw = url.trim();
if (!raw) {
const error = createAppError("URL_INVALID_FORMAT");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
const error = createAppError("URL_INVALID_FORMAT");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
const error = createAppError("URL_INVALID_FORMAT");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
if (pathParts.length < 2) {
const error = createAppError("URL_INVALID_FORMAT");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
const detectedPlatform = this.detectPlatformFromUrl(raw);
if (detectedPlatform && detectedPlatform !== platform) {
const error = createAppError("URL_PLATFORM_MISMATCH");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
if (!detectedPlatform && platform !== "gitea") {
const error = createAppError("URL_INVALID_FORMAT");
return { ok: false, code: error.payload.code, message: error.payload.message };
}
return { ok: true };
}
private getConfiguredGiteaHost(): string {
const rawBase = (process.env.PUSHGO_GITEA_BASE_URL ?? DEFAULT_GITEA_BASE_URL).trim();
try {
const normalized = rawBase.startsWith("http") ? rawBase : `https://${rawBase}`;
return new URL(normalized).hostname.toLowerCase();
} catch {
return "gitea.com";
}
}
}

View File

@@ -0,0 +1,57 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface CommandResult {
stdout: string;
stderr: string;
}
export class CommandExecutionError extends Error {
readonly stdout: string;
readonly stderr: string;
readonly code: number | string | null;
constructor(message: string, params: { stdout?: string; stderr?: string; code?: number | string | null }) {
super(message);
this.name = "CommandExecutionError";
this.stdout = params.stdout ?? "";
this.stderr = params.stderr ?? "";
this.code = params.code ?? null;
}
}
export async function runCommand(
command: string,
args: string[],
options?: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv }
): Promise<CommandResult> {
try {
const { stdout, stderr } = await execFileAsync(command, args, {
cwd: options?.cwd,
timeout: options?.timeoutMs ?? 60_000,
maxBuffer: 1024 * 1024 * 8,
env: options?.env,
windowsHide: true
});
return {
stdout: stdout.toString(),
stderr: stderr.toString()
};
} catch (error) {
const err = error as {
message?: string;
stdout?: string | Buffer;
stderr?: string | Buffer;
code?: number | string;
};
throw new CommandExecutionError(err.message ?? "命令执行失败", {
stdout: err.stdout?.toString(),
stderr: err.stderr?.toString(),
code: err.code ?? null
});
}
}

28
src/preload/preload.ts Normal file
View File

@@ -0,0 +1,28 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { IPC_CHANNELS, PUSH_PROGRESS_CHANNEL } from "../shared/channels";
import { PushGoApi } from "../shared/bridge";
import { PushProgressEvent } from "../shared/contracts";
const api: PushGoApi = {
checkGit: () => ipcRenderer.invoke(IPC_CHANNELS.CHECK_GIT),
selectFolder: () => ipcRenderer.invoke(IPC_CHANNELS.SELECT_FOLDER),
validateRepoUrl: (payload) => ipcRenderer.invoke(IPC_CHANNELS.VALIDATE_URL, payload),
authStatus: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_STATUS, payload),
authLogin: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGIN, payload),
authLogout: (payload) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT, payload),
runPush: (payload) => ipcRenderer.invoke(IPC_CHANNELS.PUSH_RUN, payload),
openExternal: (payload) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, payload),
onPushProgress: (callback) => {
const listener = (_event: IpcRendererEvent, payload: PushProgressEvent) => {
callback(payload);
};
ipcRenderer.on(PUSH_PROGRESS_CHANNEL, listener);
return () => {
ipcRenderer.removeListener(PUSH_PROGRESS_CHANNEL, listener);
};
}
};
contextBridge.exposeInMainWorld("pushgo", api);

81
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAppContext } from "./context/app-context";
import { AuthPage } from "./pages/auth-page";
import { CompletePage } from "./pages/complete-page";
import { PushPage } from "./pages/push-page";
import { RepoPage } from "./pages/repo-page";
import { SelectProjectPage } from "./pages/select-project-page";
import { StartupPage } from "./pages/startup-page";
const STEP_LABELS: Record<string, string> = {
"/": "开始前检查",
"/select": "选择平台和项目",
"/repo": "填写仓库地址",
"/auth": "登录并授权",
"/push": "正在推送",
"/done": "推送成功"
};
export default function App() {
const location = useLocation();
const { error, notice, setError } = useAppContext();
const currentLabel = STEP_LABELS[location.pathname] ?? "PushGo";
return (
<div className="app-shell">
<div className="bg-orb bg-orb-a" />
<div className="bg-orb bg-orb-b" />
<header className="topbar">
<div className="brand-line">
<span className="brand">PushGo</span>
<span className="brand-sub"> GitHub / Gitee / Gitea</span>
</div>
<div className="step-label">{currentLabel}</div>
</header>
<main className="main-wrap">
{notice ? <div className="hint success">{notice}</div> : null}
{error ? (
<div className="hint error">
<div>{error.message}</div>
{error.actionType === "open_git_download" ? (
<button
className="hint-action"
onClick={async () => {
await window.pushgo.openExternal({ url: "https://git-scm.com/downloads" });
}}
>
{error.actionText ?? "去安装 Git"}
</button>
) : null}
{error.actionType === "open_gcm_download" ? (
<button
className="hint-action"
onClick={async () => {
await window.pushgo.openExternal({ url: "https://github.com/git-ecosystem/git-credential-manager" });
}}
>
{error.actionText ?? "安装凭据助手"}
</button>
) : null}
<button className="hint-close" onClick={() => setError(null)}>
</button>
</div>
) : null}
<Routes>
<Route path="/" element={<StartupPage />} />
<Route path="/select" element={<SelectProjectPage />} />
<Route path="/repo" element={<RepoPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/push" element={<PushPage />} />
<Route path="/done" element={<CompletePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
import { ValidateUrlResult } from "../../shared/bridge";
import { AppErrorPayload, AppState, PushProgressEvent, PushRunMeta, Platform } from "../../shared/contracts";
const initialState: AppState = {
gitInstalled: false,
platform: null,
projectPath: null,
repoUrl: null,
auth: {
github: false,
gitee: false,
gitea: false
}
};
type AppContextValue = {
state: AppState;
gitChecked: boolean;
gitWarning: AppErrorPayload | null;
repoValidation: ValidateUrlResult | null;
progress: PushProgressEvent | null;
pushMeta: PushRunMeta | null;
error: AppErrorPayload | null;
notice: string | null;
setPlatform: (platform: Platform) => void;
setProjectPath: (projectPath: string | null) => void;
setRepoUrl: (repoUrl: string | null) => void;
setGitResult: (installed: boolean, warning?: AppErrorPayload) => void;
setAuthStatus: (platform: Platform, loggedIn: boolean) => void;
setRepoValidation: (result: ValidateUrlResult | null) => void;
setProgress: (progress: PushProgressEvent | null) => void;
setPushMeta: (meta: PushRunMeta | null) => void;
setError: (error: AppErrorPayload | null) => void;
setNotice: (notice: string | null) => void;
clearTransientMessages: () => void;
resetForNextPush: () => void;
};
const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AppState>(initialState);
const [gitChecked, setGitChecked] = useState(false);
const [gitWarning, setGitWarning] = useState<AppErrorPayload | null>(null);
const [repoValidation, setRepoValidationState] = useState<ValidateUrlResult | null>(null);
const [progress, setProgressState] = useState<PushProgressEvent | null>(null);
const [pushMeta, setPushMetaState] = useState<PushRunMeta | null>(null);
const [error, setErrorState] = useState<AppErrorPayload | null>(null);
const [notice, setNoticeState] = useState<string | null>(null);
const value = useMemo<AppContextValue>(
() => ({
state,
gitChecked,
gitWarning,
repoValidation,
progress,
pushMeta,
error,
notice,
setPlatform: (platform) => {
setState((prev) => ({ ...prev, platform }));
},
setProjectPath: (projectPath) => {
setState((prev) => ({ ...prev, projectPath }));
},
setRepoUrl: (repoUrl) => {
setState((prev) => ({ ...prev, repoUrl }));
},
setGitResult: (installed, warning) => {
setGitChecked(true);
setGitWarning(warning ?? null);
setState((prev) => ({ ...prev, gitInstalled: installed }));
},
setAuthStatus: (platform, loggedIn) => {
setState((prev) => {
if (prev.auth[platform] === loggedIn) {
return prev;
}
return {
...prev,
auth: {
...prev.auth,
[platform]: loggedIn
}
};
});
},
setRepoValidation: (result) => {
setRepoValidationState(result);
},
setProgress: (nextProgress) => {
setProgressState(nextProgress);
},
setPushMeta: (meta) => {
setPushMetaState(meta);
},
setError: (nextError) => {
setErrorState(nextError);
},
setNotice: (nextNotice) => {
setNoticeState(nextNotice);
},
clearTransientMessages: () => {
setErrorState(null);
setNoticeState(null);
},
resetForNextPush: () => {
setProgressState(null);
setPushMetaState(null);
setErrorState(null);
setNoticeState(null);
}
}),
[state, gitChecked, gitWarning, repoValidation, progress, pushMeta, error, notice]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error("useAppContext must be used inside AppProvider");
}
return ctx;
}

11
src/renderer/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import type { PushGoApi } from "../shared/bridge";
declare global {
interface Window {
pushgo: PushGoApi;
}
}
export {};

16
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";
import App from "./App";
import { AppProvider } from "./context/app-context";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<HashRouter>
<AppProvider>
<App />
</AppProvider>
</HashRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppContext } from "../context/app-context";
const AUTH_STATUS_TIMEOUT_MS = 8_000;
export function AuthPage() {
const navigate = useNavigate();
const { state, setAuthStatus, setError, clearTransientMessages } = useAppContext();
const [checking, setChecking] = useState(false);
const [working, setWorking] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
useEffect(() => {
if (!state.platform || !state.repoUrl || !state.projectPath) {
navigate("/select", { replace: true });
return;
}
let cancelled = false;
const checkStatus = async () => {
setChecking(true);
try {
const result = await Promise.race([
window.pushgo.authStatus({ platform: state.platform!, repoUrl: state.repoUrl! }),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("AUTH_STATUS_TIMEOUT")), AUTH_STATUS_TIMEOUT_MS);
})
]);
if (result.ok) {
if (!cancelled) {
setLoggedIn(result.data.loggedIn);
setAuthStatus(state.platform!, result.data.loggedIn);
}
} else {
if (!cancelled) {
setError(result.error);
}
}
} catch {
if (!cancelled) {
setError({ code: "UNKNOWN", message: "登录状态检查失败,请点击“去登录授权”继续" });
}
} finally {
if (!cancelled) {
setChecking(false);
}
}
};
void checkStatus();
return () => {
cancelled = true;
};
}, [navigate, state.platform, state.projectPath, state.repoUrl]);
const proceed = async () => {
if (!state.platform) {
return;
}
setWorking(true);
clearTransientMessages();
try {
if (!loggedIn) {
const loginResult = await window.pushgo.authLogin({ platform: state.platform, repoUrl: state.repoUrl! });
if (!loginResult.ok) {
setError(loginResult.error);
return;
}
setAuthStatus(state.platform, true);
setLoggedIn(true);
}
navigate("/push");
} catch {
setError({ code: "UNKNOWN", message: "发起授权失败,请重试" });
} finally {
setWorking(false);
}
};
const platformLabel =
state.platform === "github" ? "GitHub" : state.platform === "gitee" ? "Gitee" : "Gitea";
return (
<section className="panel fade-in">
<h1></h1>
<p className="desc"> {platformLabel} </p>
<p className="desc"> OAuth Git </p>
<div className={`status ${loggedIn ? "ok" : "warn"}`}>
{checking ? "正在检查登录状态..." : loggedIn ? `已登录 ${platformLabel},可以继续` : `当前会话未登录 ${platformLabel}`}
</div>
<div className="actions">
<button className="ghost" onClick={() => navigate("/repo")}></button>
<button className="primary" onClick={proceed} disabled={checking || working}>
{working ? "处理中..." : loggedIn ? "开始推送" : "去登录授权"}
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,57 @@
import { useNavigate } from "react-router-dom";
import { useAppContext } from "../context/app-context";
export function CompletePage() {
const navigate = useNavigate();
const { state, pushMeta, setNotice, setError, resetForNextPush } = useAppContext();
const repoUrl = state.repoUrl ?? "";
const copyRepo = async () => {
try {
await navigator.clipboard.writeText(repoUrl);
setNotice("仓库链接已复制");
} catch {
setError({ code: "UNKNOWN", message: "复制失败,请手动复制链接" });
}
};
const openRepo = async () => {
if (!repoUrl) {
return;
}
const result = await window.pushgo.openExternal({ url: repoUrl });
if (!result.ok) {
setError(result.error);
}
};
return (
<section className="panel fade-in">
<h1></h1>
<p className="repo-url">{repoUrl}</p>
{pushMeta?.fallbackToMaster ? <p className="desc">退 master </p> : null}
<div className="actions">
<button className="ghost" onClick={copyRepo}>
</button>
<button className="ghost" onClick={openRepo}>
</button>
<button
className="primary"
onClick={() => {
resetForNextPush();
navigate("/select");
}}
>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PushStage } from "../../shared/contracts";
import { useAppContext } from "../context/app-context";
const STEP_ORDER: Array<{ key: PushStage; label: string }> = [
{ key: "prepare", label: "准备仓库" },
{ key: "collect", label: "整理文件" },
{ key: "commit", label: "提交变更" },
{ key: "upload", label: "上传到远端" }
];
export function PushPage() {
const navigate = useNavigate();
const { state, progress, setProgress, setPushMeta, setError, setNotice, clearTransientMessages } = useAppContext();
const [running, setRunning] = useState(false);
const startedRef = useRef(false);
useEffect(() => {
if (!state.platform || !state.projectPath || !state.repoUrl) {
navigate("/select", { replace: true });
}
}, [navigate, state.platform, state.projectPath, state.repoUrl]);
useEffect(() => {
const unsubscribe = window.pushgo.onPushProgress((event) => {
setProgress(event);
});
return () => {
unsubscribe();
};
}, [setProgress]);
const runPush = useCallback(async () => {
if (!state.platform || !state.projectPath || !state.repoUrl || running) {
return;
}
setRunning(true);
clearTransientMessages();
const result = await window.pushgo.runPush({
platform: state.platform,
projectPath: state.projectPath,
repoUrl: state.repoUrl
});
if (result.ok) {
setPushMeta(result.data.meta);
const notices: string[] = [];
if (result.data.meta.commitStatus === "no_changes") {
notices.push("没有检测到新变更,已尝试直接推送");
}
if (result.data.meta.fallbackToMaster) {
notices.push("远端仅支持 master已自动回退并完成推送");
}
if (notices.length > 0) {
setNotice(notices.join(""));
}
navigate("/done");
} else {
setError(result.error);
}
setRunning(false);
}, [
clearTransientMessages,
navigate,
running,
setError,
setNotice,
setPushMeta,
state.platform,
state.projectPath,
state.repoUrl
]);
useEffect(() => {
if (!state.platform || !state.projectPath || !state.repoUrl || startedRef.current) {
return;
}
startedRef.current = true;
void runPush();
}, [runPush, state.platform, state.projectPath, state.repoUrl]);
const activeIndex = useMemo(() => {
const currentStage = progress?.stage ?? "prepare";
const idx = STEP_ORDER.findIndex((item) => item.key === currentStage);
return idx === -1 ? 0 : idx;
}, [progress?.stage]);
return (
<section className="panel fade-in">
<h1></h1>
<ul className="progress-list">
{STEP_ORDER.map((item, index) => (
<li
key={item.key}
className={`progress-item ${index < activeIndex ? "done" : ""} ${index === activeIndex ? "active" : ""}`}
>
<span>{item.label}</span>
</li>
))}
</ul>
<div className="actions">
{!running ? (
<button className="ghost" onClick={() => navigate("/auth")}>
</button>
) : null}
<button className="primary" disabled={running} onClick={() => void runPush()}>
{running ? "正在推送..." : "重新推送"}
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Platform } from "../../shared/contracts";
import { useAppContext } from "../context/app-context";
const PLATFORM_TEXT: Record<Platform, string> = {
github: "GitHub",
gitee: "Gitee",
gitea: "Gitea"
};
export function RepoPage() {
const navigate = useNavigate();
const { state, setPlatform, setRepoUrl, setRepoValidation, repoValidation, setError, clearTransientMessages } =
useAppContext();
const [urlInput, setUrlInput] = useState(state.repoUrl ?? "");
const [validating, setValidating] = useState(false);
useEffect(() => {
if (!state.platform || !state.projectPath) {
navigate("/select", { replace: true });
}
}, [navigate, state.platform, state.projectPath]);
useEffect(() => {
setUrlInput(state.repoUrl ?? "");
}, [state.repoUrl]);
const canSubmit = useMemo(() => Boolean(urlInput.trim()), [urlInput]);
const validateNow = async () => {
if (!state.platform) {
return;
}
setValidating(true);
setRepoUrl(urlInput.trim());
clearTransientMessages();
const result = await window.pushgo.validateRepoUrl({
url: urlInput.trim(),
platform: state.platform
});
if (result.ok) {
setRepoValidation(result.data);
if (result.data.ok) {
navigate("/auth");
} else {
setError({
code: result.data.code ?? "UNKNOWN",
message: result.data.message ?? "仓库地址格式不正确,请检查后重试"
});
}
} else {
setError(result.error);
}
setValidating(false);
};
const mismatchPlatform =
repoValidation?.code === "URL_PLATFORM_MISMATCH" && repoValidation.detectedPlatform
? repoValidation.detectedPlatform
: null;
return (
<section className="panel fade-in">
<h1></h1>
<p className="desc"> https://github.com/name/repo.git 或 https://gitea.com/name/repo.git</p>
<label className="field-label" htmlFor="repo-url">
URL
</label>
<input
id="repo-url"
className="input"
placeholder="粘贴仓库地址,例如 https://github.com/name/repo.git 或 https://gitea.com/name/repo.git"
value={urlInput}
onChange={(event) => {
setUrlInput(event.target.value);
setRepoValidation(null);
}}
/>
{repoValidation?.ok ? <p className="validation ok"></p> : null}
{repoValidation && !repoValidation.ok ? (
<p className="validation error">{repoValidation.message ?? "仓库地址格式不正确,请检查后重试"}</p>
) : null}
{mismatchPlatform ? (
<button
className="ghost"
onClick={() => {
setPlatform(mismatchPlatform);
setRepoValidation(null);
clearTransientMessages();
}}
>
{PLATFORM_TEXT[mismatchPlatform]}
</button>
) : null}
<div className="actions">
<button className="ghost" onClick={() => navigate("/select")}>
</button>
<button className="primary" disabled={!canSubmit || validating} onClick={validateNow}>
{validating ? "校验中..." : "下一步"}
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Platform } from "../../shared/contracts";
import { useAppContext } from "../context/app-context";
const PLATFORM_LIST: Array<{ key: Platform; label: string; desc: string }> = [
{ key: "github", label: "GitHub", desc: "适合开源与国际协作" },
{ key: "gitee", label: "Gitee", desc: "适合国内网络环境" },
{ key: "gitea", label: "Gitea", desc: "适合私有化或自建代码服务" }
];
export function SelectProjectPage() {
const navigate = useNavigate();
const { state, setPlatform, setProjectPath, setError, clearTransientMessages } = useAppContext();
const [browsing, setBrowsing] = useState(false);
const browseFolder = async () => {
setBrowsing(true);
const result = await window.pushgo.selectFolder();
if (result.ok) {
setProjectPath(result.data.path);
clearTransientMessages();
} else {
setError(result.error);
}
setBrowsing(false);
};
const canNext = Boolean(state.platform && state.projectPath);
return (
<section className="panel fade-in">
<h1></h1>
<div className="platform-grid">
{PLATFORM_LIST.map((item) => (
<button
key={item.key}
className={`platform-item ${state.platform === item.key ? "active" : ""}`}
onClick={() => setPlatform(item.key)}
>
<span className="platform-name">{item.label}</span>
<span className="platform-desc">{item.desc}</span>
</button>
))}
</div>
<div className="drop-zone browse-only">
<p></p>
<button className="ghost" onClick={browseFolder} disabled={browsing}>
{browsing ? "打开中..." : "点击浏览"}
</button>
</div>
{state.projectPath ? (
<p className="path-view">
<span>{state.projectPath}</span>
<button className="link-like" onClick={browseFolder}>
</button>
</p>
) : null}
<div className="actions">
<button className="ghost" onClick={() => navigate("/")}>
</button>
<button
className="primary"
disabled={!canNext}
onClick={() => {
navigate("/repo");
}}
>
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAppContext } from "../context/app-context";
export function StartupPage() {
const navigate = useNavigate();
const { state, gitChecked, gitWarning, setGitResult, setError, clearTransientMessages } = useAppContext();
const [checking, setChecking] = useState(false);
const runCheck = useCallback(async () => {
setChecking(true);
clearTransientMessages();
const result = await window.pushgo.checkGit();
if (result.ok) {
setGitResult(result.data.installed, result.data.warning);
if (result.data.warning) {
setError(result.data.warning);
}
} else {
setGitResult(false, result.error);
setError(result.error);
}
setChecking(false);
}, [clearTransientMessages, setError, setGitResult]);
useEffect(() => {
if (!gitChecked) {
void runCheck();
}
}, [gitChecked, runCheck]);
const statusText = !state.gitInstalled
? "未检测到 Git安装后即可使用"
: gitWarning
? "已安装 Git但授权组件未就绪请按提示处理"
: "已检测到 Git 和授权组件,可继续";
return (
<section className="panel fade-in">
<h1></h1>
<p className="desc"> Git</p>
<div className={`status ${state.gitInstalled && !gitWarning ? "ok" : "warn"}`}>{statusText}</div>
<div className="actions">
{!state.gitInstalled ? (
<button
className="ghost"
onClick={async () => {
await window.pushgo.openExternal({ url: "https://git-scm.com/downloads" });
}}
>
Git
</button>
) : null}
<button className="ghost" onClick={runCheck} disabled={checking}>
{checking ? "检测中..." : "重新检测"}
</button>
<button
className="primary"
disabled={!state.gitInstalled || checking}
onClick={() => {
navigate("/select");
}}
>
</button>
</div>
</section>
);
}

424
src/renderer/styles.css Normal file
View File

@@ -0,0 +1,424 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&display=swap");
:root {
--bg-0: #07131f;
--bg-1: #10263d;
--panel: rgba(7, 23, 38, 0.8);
--panel-border: rgba(157, 196, 232, 0.24);
--text: #f3f9ff;
--text-dim: rgba(234, 245, 255, 0.72);
--accent: #5fe0b9;
--accent-strong: #1bb28a;
--warn: #ffb86c;
--danger: #ff6e6e;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
color: var(--text);
background: radial-gradient(120% 120% at 10% 0%, #11365a 0%, var(--bg-0) 55%, #040c14 100%);
}
button,
input {
font: inherit;
}
.app-shell {
min-height: 100vh;
position: relative;
overflow: hidden;
padding: 2rem clamp(1rem, 3vw, 2.5rem);
}
.bg-orb {
position: absolute;
border-radius: 999px;
filter: blur(14px);
opacity: 0.22;
pointer-events: none;
animation: float 16s ease-in-out infinite;
}
.bg-orb-a {
width: 280px;
height: 280px;
background: #6f9dff;
top: -70px;
right: -40px;
}
.bg-orb-b {
width: 340px;
height: 340px;
background: #2cc8a9;
bottom: -120px;
left: -80px;
animation-delay: -4s;
}
.topbar {
position: relative;
z-index: 2;
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.4rem;
}
.brand-line {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.brand {
font-family: "Space Grotesk", "Noto Sans SC", sans-serif;
font-size: clamp(1.8rem, 2.6vw, 2.4rem);
letter-spacing: 0.02em;
font-weight: 700;
}
.brand-sub {
color: var(--text-dim);
font-size: 0.94rem;
}
.step-label {
color: rgba(243, 249, 255, 0.82);
font-weight: 500;
font-size: 0.96rem;
background: rgba(13, 33, 51, 0.8);
border: 1px solid rgba(165, 199, 228, 0.28);
border-radius: 999px;
padding: 0.5rem 1rem;
}
.main-wrap {
position: relative;
z-index: 2;
max-width: 900px;
}
.panel {
background: linear-gradient(165deg, rgba(9, 31, 48, 0.94), rgba(5, 19, 32, 0.9));
border: 1px solid var(--panel-border);
border-radius: 22px;
padding: clamp(1.2rem, 3vw, 2.1rem);
backdrop-filter: blur(8px);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
}
.panel h1 {
margin: 0;
font-family: "Space Grotesk", "Noto Sans SC", sans-serif;
font-size: clamp(1.45rem, 2.3vw, 2rem);
}
.desc {
margin-top: 0.7rem;
color: var(--text-dim);
line-height: 1.55;
}
.status {
margin-top: 1.3rem;
border-radius: 12px;
padding: 0.78rem 1rem;
font-weight: 500;
border: 1px solid transparent;
}
.status.ok {
border-color: rgba(95, 224, 185, 0.45);
background: rgba(25, 74, 64, 0.48);
}
.status.warn {
border-color: rgba(255, 184, 108, 0.42);
background: rgba(96, 62, 19, 0.36);
}
.platform-grid {
margin-top: 1.2rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.platform-item {
border: 1px solid rgba(167, 198, 225, 0.28);
border-radius: 14px;
background: rgba(7, 29, 45, 0.66);
color: var(--text);
text-align: left;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.32rem;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.platform-item:hover {
transform: translateY(-2px);
}
.platform-item.active {
border-color: rgba(95, 224, 185, 0.75);
background: rgba(17, 67, 58, 0.62);
}
.platform-name {
font-weight: 700;
}
.platform-desc {
font-size: 0.88rem;
color: var(--text-dim);
}
.drop-zone {
margin-top: 1rem;
border: 1.5px dashed rgba(164, 203, 231, 0.48);
border-radius: 16px;
padding: 1.3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: rgba(9, 35, 53, 0.45);
}
.path-view {
margin-top: 0.9rem;
color: rgba(230, 244, 255, 0.86);
word-break: break-all;
}
.path-view span {
color: #7ee6c6;
}
.field-label {
margin-top: 1.2rem;
display: inline-block;
font-size: 0.92rem;
color: rgba(232, 244, 255, 0.82);
}
.input {
width: 100%;
margin-top: 0.45rem;
border-radius: 12px;
border: 1px solid rgba(167, 198, 225, 0.35);
background: rgba(7, 28, 44, 0.88);
color: var(--text);
padding: 0.72rem 0.9rem;
}
.input:focus {
outline: none;
border-color: rgba(95, 224, 185, 0.88);
}
.validation {
margin-top: 0.66rem;
font-size: 0.92rem;
}
.validation.ok {
color: #88eccf;
}
.validation.error {
color: #ffb0a4;
}
.repo-url {
margin-top: 0.9rem;
padding: 0.75rem 0.95rem;
border-radius: 10px;
background: rgba(7, 27, 43, 0.72);
border: 1px solid rgba(153, 195, 226, 0.32);
word-break: break-all;
}
.progress-list {
margin: 1.1rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.66rem;
}
.progress-item {
border: 1px solid rgba(161, 201, 230, 0.28);
background: rgba(10, 33, 50, 0.55);
border-radius: 12px;
padding: 0.78rem 0.95rem;
}
.progress-item.active {
border-color: rgba(95, 224, 185, 0.8);
animation: pulse 1.5s ease infinite;
}
.progress-item.done {
border-color: rgba(95, 224, 185, 0.5);
color: rgba(213, 243, 233, 0.92);
}
.actions {
margin-top: 1.3rem;
display: flex;
flex-wrap: wrap;
gap: 0.62rem;
}
.actions.single {
justify-content: flex-end;
}
button {
border: 0;
border-radius: 12px;
padding: 0.68rem 1.15rem;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
}
button:hover {
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
.primary {
color: #02251e;
font-weight: 700;
background: linear-gradient(120deg, var(--accent), #9ef7d8);
}
.ghost {
color: var(--text);
border: 1px solid rgba(168, 200, 228, 0.34);
background: rgba(12, 37, 56, 0.62);
}
.link-like {
margin-left: 0.45rem;
padding: 0;
border: 0;
background: transparent;
color: #9be8cf;
cursor: pointer;
}
.hint {
margin-bottom: 0.85rem;
border-radius: 13px;
border: 1px solid transparent;
padding: 0.75rem 0.9rem;
display: flex;
gap: 0.65rem;
align-items: center;
flex-wrap: wrap;
}
.hint.success {
border-color: rgba(95, 224, 185, 0.52);
background: rgba(20, 76, 64, 0.38);
}
.hint.error {
border-color: rgba(255, 129, 129, 0.5);
background: rgba(96, 34, 34, 0.42);
}
.hint-action,
.hint-close {
background: rgba(255, 255, 255, 0.15);
color: #f8fdff;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0.42rem 0.72rem;
border-radius: 8px;
}
.fade-in {
animation: slideIn 0.45s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(95, 224, 185, 0.35);
}
70% {
box-shadow: 0 0 0 10px rgba(95, 224, 185, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(95, 224, 185, 0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(14px);
}
}
@media (max-width: 900px) {
.app-shell {
padding: 1rem;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
.platform-grid {
grid-template-columns: 1fr;
}
.drop-zone {
flex-direction: column;
align-items: flex-start;
}
.actions,
.actions.single {
justify-content: stretch;
}
.actions button {
width: 100%;
}
}

30
src/shared/bridge.ts Normal file
View File

@@ -0,0 +1,30 @@
import { IpcResult, Platform, PushProgressEvent, PushRunMeta, PushRunParams } from "./contracts";
export type CheckGitResult = {
installed: boolean;
warning?: {
code: string;
message: string;
actionText?: string;
actionType?: string;
};
};
export type ValidateUrlResult = {
ok: boolean;
code?: string;
message?: string;
detectedPlatform: Platform | null;
};
export interface PushGoApi {
checkGit(): Promise<IpcResult<CheckGitResult>>;
selectFolder(): Promise<IpcResult<{ path: string | null }>>;
validateRepoUrl(payload: { url: string; platform: Platform }): Promise<IpcResult<ValidateUrlResult>>;
authStatus(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
authLogin(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
authLogout(payload: { platform: Platform; repoUrl?: string }): Promise<IpcResult<{ loggedIn: boolean }>>;
runPush(payload: PushRunParams): Promise<IpcResult<{ meta: PushRunMeta }>>;
openExternal(payload: { url: string }): Promise<IpcResult<{ opened: boolean }>>;
onPushProgress(callback: (event: PushProgressEvent) => void): () => void;
}

12
src/shared/channels.ts Normal file
View File

@@ -0,0 +1,12 @@
export const IPC_CHANNELS = {
CHECK_GIT: "app:check-git",
OPEN_EXTERNAL: "app:open-external",
AUTH_LOGIN: "auth:login",
AUTH_LOGOUT: "auth:logout",
AUTH_STATUS: "auth:status",
VALIDATE_URL: "repo:validate-url",
SELECT_FOLDER: "git:select-folder",
PUSH_RUN: "push:run"
} as const;
export const PUSH_PROGRESS_CHANNEL = "push:progress";

87
src/shared/contracts.ts Normal file
View File

@@ -0,0 +1,87 @@
export type Platform = "github" | "gitee" | "gitea";
export interface AppState {
gitInstalled: boolean;
platform: Platform | null;
projectPath: string | null;
repoUrl: string | null;
auth: { github: boolean; gitee: boolean; gitea: boolean };
}
export interface GitService {
checkInstalled(): Promise<boolean>;
isRepo(projectPath: string): Promise<boolean>;
initRepo(projectPath: string): Promise<void>;
ensureUserConfig(projectPath: string): Promise<void>;
addAll(projectPath: string): Promise<void>;
commit(projectPath: string, message: string): Promise<"committed" | "no_changes">;
setRemote(projectPath: string, repoUrl: string): Promise<void>;
push(projectPath: string): Promise<void>;
}
export interface AuthService {
isLoggedIn(platform: Platform, options?: { repoUrl?: string }): Promise<boolean>;
login(platform: Platform, options?: { repoUrl?: string }): Promise<void>;
logout(platform: Platform, options?: { repoUrl?: string }): Promise<void>;
}
export interface RepoService {
validateRepoUrl(url: string, platform: Platform): { ok: boolean; code?: string; message?: string };
detectPlatformFromUrl(url: string): Platform | null;
}
export interface PushOrchestrator {
run(params: { platform: Platform; projectPath: string; repoUrl: string }): Promise<void>;
}
export type PushStage = "prepare" | "collect" | "commit" | "upload";
export interface PushProgressEvent {
stage: PushStage;
message: string;
}
export interface PushRunMeta {
commitStatus: "committed" | "no_changes";
fallbackToMaster: boolean;
}
export type ErrorCode =
| "APP_GIT_NOT_FOUND"
| "APP_GIT_HELPER_MISSING"
| "APP_GIT_HELPER_UNSUPPORTED"
| "APP_FOLDER_INVALID"
| "URL_INVALID_FORMAT"
| "URL_PLATFORM_MISMATCH"
| "AUTH_CANCELLED"
| "AUTH_FAILED"
| "GIT_INIT_FAILED"
| "GIT_COMMIT_NO_CHANGES"
| "GIT_REMOTE_SET_FAILED"
| "GIT_PUSH_AUTH_FAILED"
| "GIT_PUSH_REJECTED"
| "NETWORK_TIMEOUT"
| "UNKNOWN";
export interface AppErrorPayload {
code: ErrorCode | string;
message: string;
actionText?: string;
actionType?: string;
}
export type IpcResult<T> =
| {
ok: true;
data: T;
}
| {
ok: false;
error: AppErrorPayload;
};
export interface PushRunParams {
platform: Platform;
projectPath: string;
repoUrl: string;
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["src/renderer", "src/shared", "vite.config.ts"]
}

17
tsconfig.main.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022", "DOM"],
"moduleResolution": "Node",
"outDir": "dist-electron",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node", "electron"],
"sourceMap": true
},
"include": ["src/main/**/*.ts", "src/preload/**/*.ts", "src/shared/**/*.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true
}
});