PushGo Auto Commit 2026-03-27T01:00:57.339Z
This commit is contained in:
158
README.md
Normal file
158
README.md
Normal 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
12
index.html
Normal 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
57
package.json
Normal 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
167
src/main/errors.ts
Normal 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
131
src/main/ipc.ts
Normal 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
102
src/main/main.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
332
src/main/services/auth-service.ts
Normal file
332
src/main/services/auth-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
611
src/main/services/git-service.ts
Normal file
611
src/main/services/git-service.ts
Normal 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]@");
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main/services/push-orchestrator.ts
Normal file
76
src/main/services/push-orchestrator.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/main/services/repo-service.ts
Normal file
89
src/main/services/repo-service.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/utils/run-command.ts
Normal file
57
src/main/utils/run-command.ts
Normal 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
28
src/preload/preload.ts
Normal 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
81
src/renderer/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/renderer/context/app-context.tsx
Normal file
130
src/renderer/context/app-context.tsx
Normal 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
11
src/renderer/env.d.ts
vendored
Normal 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
16
src/renderer/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
109
src/renderer/pages/auth-page.tsx
Normal file
109
src/renderer/pages/auth-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/renderer/pages/complete-page.tsx
Normal file
57
src/renderer/pages/complete-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/renderer/pages/push-page.tsx
Normal file
122
src/renderer/pages/push-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/renderer/pages/repo-page.tsx
Normal file
114
src/renderer/pages/repo-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/renderer/pages/select-project-page.tsx
Normal file
80
src/renderer/pages/select-project-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/renderer/pages/startup-page.tsx
Normal file
75
src/renderer/pages/startup-page.tsx
Normal 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
424
src/renderer/styles.css
Normal 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
30
src/shared/bridge.ts
Normal 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
12
src/shared/channels.ts
Normal 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
87
src/shared/contracts.ts
Normal 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
17
tsconfig.json
Normal 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
17
tsconfig.main.json
Normal 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
11
vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user