feat: add configuration backup, sync, and version checker services

- Implemented ConfigBackupService for backing up local configuration files.
- Added ConfigSyncService to download configuration files from GitHub with remote-first conflict resolution.
- Created VersionChecker to check application version against the latest GitHub release with caching.
- Introduced security validation utilities for input validation to prevent common vulnerabilities.
- Developed utility functions to start and stop Docusaurus documentation server.
This commit is contained in:
catlog22
2026-02-05 17:32:31 +08:00
parent 834951a08d
commit 5cfeb59124
265 changed files with 8714 additions and 1408 deletions

View File

@@ -0,0 +1,336 @@
// ========================================
// Config Sync Component
// ========================================
// UI for GitHub config sync, backup operations, and directory selection
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Github,
RefreshCw,
HardDrive,
FolderOpen,
CheckCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ConfigSyncModal } from './ConfigSyncModal';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface ConfigSyncProps {
className?: string;
}
export interface BackupInfo {
id: string;
timestamp: string;
fileCount: number;
sizeBytes: number;
configDirs: string[];
}
export interface SyncResult {
success: boolean;
syncedFiles: string[];
error?: string;
}
export interface BackupResult {
success: boolean;
fileCount: number;
backupPath?: string;
error?: string;
}
// ========== Constants ==========
const AVAILABLE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'] as const;
const DEFAULT_REPO_URL = 'https://github.com/dyw0830/ccw';
const DEFAULT_BRANCH = 'main';
// ========== Main Component ==========
export function ConfigSync({ className }: ConfigSyncProps) {
const { formatMessage } = useIntl();
const [syncing, setSyncing] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [showModal, setShowModal] = useState(false);
const [repoUrl, setRepoUrl] = useState(DEFAULT_REPO_URL);
const [branch, setBranch] = useState(DEFAULT_BRANCH);
const [selectedDirs, setSelectedDirs] = useState<string[]>(['.claude']);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [lastBackupTime, setLastBackupTime] = useState<Date | null>(null);
// Backup list loaded for future display (TODO: add backup list UI)
const [_backupList, setBackupList] = useState<BackupInfo[]>([]);
// Load backups on mount
useEffect(() => {
loadBackups();
}, []);
// Parse owner and repo from URL
const parseRepoInfo = (): { owner: string; repo: string } | null => {
try {
const url = new URL(repoUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
if (pathParts.length >= 2) {
return { owner: pathParts[0], repo: pathParts[1] };
}
return null;
} catch {
return null;
}
};
// Handle sync operation
const handleSync = async () => {
const repoInfo = parseRepoInfo();
if (!repoInfo) {
toast.error('Invalid GitHub repository URL');
return;
}
if (selectedDirs.length === 0) {
toast.error('Please select at least one configuration directory');
return;
}
setSyncing(true);
try {
const response = await fetch('/api/config/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner: repoInfo.owner,
repo: repoInfo.repo,
branch,
configDirs: selectedDirs
})
});
const data: SyncResult = await response.json();
if (data.success) {
setLastSyncTime(new Date());
toast.success(`Sync completed! ${data.syncedFiles.length} file(s) updated`, {
description: data.syncedFiles.slice(0, 3).join(', ') + (data.syncedFiles.length > 3 ? '...' : '')
});
} else {
toast.error('Sync failed', {
description: data.error || 'Unknown error'
});
}
} catch (error) {
toast.error('Sync failed', {
description: error instanceof Error ? error.message : 'Unknown error'
});
} finally {
setSyncing(false);
}
};
// Handle backup operation
const handleBackup = async () => {
if (selectedDirs.length === 0) {
toast.error('Please select at least one configuration directory');
return;
}
setBackingUp(true);
try {
const response = await fetch('/api/config/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configDirs: selectedDirs })
});
const data: BackupResult = await response.json();
if (data.success) {
setLastBackupTime(new Date());
toast.success(`Backup completed! ${data.fileCount} file(s) backed up`, {
description: data.backupPath || 'Backup created successfully'
});
loadBackups();
} else {
toast.error('Backup failed', {
description: data.error || 'Unknown error'
});
}
} catch (error) {
toast.error('Backup failed', {
description: error instanceof Error ? error.message : 'Unknown error'
});
} finally {
setBackingUp(false);
}
};
// Load available backups
const loadBackups = async () => {
try {
const response = await fetch('/api/config/backups');
const data = await response.json();
if (data.success) {
setBackupList(data.data || []);
}
} catch (error) {
console.error('Failed to load backups:', error);
}
};
// Toggle directory selection
const toggleDir = (dir: string) => {
setSelectedDirs((prev) =>
prev.includes(dir) ? prev.filter((d) => d !== dir) : [...prev, dir]
);
};
return (
<Card className={className}>
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'configSync.title' }) || 'Configuration Sync & Backup'}
</h3>
</div>
{(lastSyncTime || lastBackupTime) && (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{lastSyncTime && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Last sync: {lastSyncTime.toLocaleTimeString()}
</span>
)}
{lastBackupTime && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
Last backup: {lastBackupTime.toLocaleTimeString()}
</span>
)}
</div>
)}
</div>
{/* GitHub Configuration */}
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'configSync.githubRepo' }) || 'GitHub Repository'}
</label>
<Input
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/owner/repo"
/>
<div className="flex gap-2">
<Input
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={formatMessage({ id: 'configSync.branch' }) || 'Branch (e.g., main)'}
className="flex-1"
/>
<Button
onClick={() => setShowModal(true)}
variant="outline"
className="shrink-0"
>
<FolderOpen className="w-4 h-4 mr-2" />
{formatMessage({ id: 'configSync.selectDirs' }) || 'Select Directories'}
</Button>
</div>
</div>
{/* Selected Directories */}
{selectedDirs.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'configSync.selectedDirs' }) || 'Selected Directories'}
</label>
<div className="flex flex-wrap gap-2">
{selectedDirs.map((dir) => (
<Badge
key={dir}
variant="secondary"
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground transition-colors"
onClick={() => toggleDir(dir)}
>
{dir}
<button className="ml-1 hover:text-white" onClick={(e) => {
e.stopPropagation();
toggleDir(dir);
}}>
×
</button>
</Badge>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleSync}
disabled={syncing || selectedDirs.length === 0}
className="w-full"
>
<RefreshCw className={cn("w-4 h-4 mr-2", syncing && "animate-spin")} />
{syncing
? (formatMessage({ id: 'configSync.syncing' }) || 'Syncing...')
: (formatMessage({ id: 'configSync.syncNow' }) || 'Sync Now')
}
</Button>
<Button
onClick={handleBackup}
disabled={backingUp || selectedDirs.length === 0}
variant="secondary"
className="w-full"
>
<HardDrive className={cn("w-4 h-4 mr-2", backingUp && "animate-pulse")} />
{backingUp
? (formatMessage({ id: 'configSync.backingUp' }) || 'Backing up...')
: (formatMessage({ id: 'configSync.backupNow' }) || 'Backup Now')
}
</Button>
</div>
{/* Available Directories Quick Select */}
<div className="pt-4 border-t border-border">
<label className="text-sm font-medium text-foreground mb-2 block">
{formatMessage({ id: 'configSync.availableDirs' }) || 'Quick Select'}
</label>
<div className="flex flex-wrap gap-2">
{AVAILABLE_DIRS.map((dir) => (
<Badge
key={dir}
variant={selectedDirs.includes(dir) ? 'default' : 'outline'}
className="cursor-pointer hover:bg-accent transition-colors"
onClick={() => toggleDir(dir)}
>
{dir}
</Badge>
))}
</div>
</div>
</div>
{/* Directory Selection Modal */}
{showModal && (
<ConfigSyncModal
selectedDirs={selectedDirs}
onSelectedDirsChange={setSelectedDirs}
onClose={() => setShowModal(false)}
/>
)}
</Card>
);
}
export default ConfigSync;

View File

@@ -0,0 +1,182 @@
// ========================================
// Config Sync Modal Component
// ========================================
// Modal for selecting configuration directories to sync/backup
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Checkbox } from '@/components/ui/Checkbox';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Folder, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
// ========== Constants ==========
const AVAILABLE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'] as const;
const DIR_DESCRIPTIONS: Record<string, string> = {
'.claude': 'CCW global configuration and workflow settings',
'.codex': 'Codex CLI tool configuration and history',
'.gemini': 'Gemini CLI tool configuration and history',
'.qwen': 'Qwen CLI tool configuration and history',
};
// ========== Types ==========
export interface ConfigSyncModalProps {
selectedDirs: string[];
onSelectedDirsChange: (dirs: string[]) => void;
onClose: () => void;
}
// ========== Helper Components ==========
interface DirectoryItemProps {
dir: string;
description: string;
selected: boolean;
onToggle: () => void;
}
function DirectoryItem({ dir, description, selected, onToggle }: DirectoryItemProps) {
return (
<div
className={cn(
"flex items-start gap-3 p-3 rounded-lg border transition-colors cursor-pointer",
"hover:bg-accent hover:border-accent-foreground/20",
selected && "bg-accent/50 border-accent-foreground/30"
)}
onClick={onToggle}
>
<Checkbox
checked={selected}
onCheckedChange={onToggle}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{selected ? (
<FolderOpen className="w-4 h-4 text-primary" />
) : (
<Folder className="w-4 h-4 text-muted-foreground" />
)}
<Label htmlFor={`dir-${dir}`} className="font-medium cursor-pointer">
{dir}
</Label>
</div>
<p className="text-sm text-muted-foreground mt-1">
{description}
</p>
</div>
</div>
);
}
// ========== Main Component ==========
export function ConfigSyncModal({
selectedDirs,
onSelectedDirsChange,
onClose
}: ConfigSyncModalProps) {
const { formatMessage } = useIntl();
const toggleDir = (dir: string) => {
if (selectedDirs.includes(dir)) {
onSelectedDirsChange(selectedDirs.filter((d) => d !== dir));
} else {
onSelectedDirsChange([...selectedDirs, dir]);
}
};
const selectAll = () => {
onSelectedDirsChange([...AVAILABLE_DIRS]);
};
const clearAll = () => {
onSelectedDirsChange([]);
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5" />
{formatMessage({ id: 'configSync.modal.title' }) || 'Select Configuration Directories'}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'configSync.modal.description' }) ||
'Choose which configuration directories to sync or backup'}
</DialogDescription>
</DialogHeader>
{/* Quick Actions */}
<div className="flex gap-2 pb-2">
<Button
variant="outline"
size="sm"
onClick={selectAll}
disabled={selectedDirs.length === AVAILABLE_DIRS.length}
className="flex-1"
>
{formatMessage({ id: 'configSync.modal.selectAll' }) || 'Select All'}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAll}
disabled={selectedDirs.length === 0}
className="flex-1"
>
{formatMessage({ id: 'configSync.modal.clearAll' }) || 'Clear All'}
</Button>
</div>
{/* Directory List */}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{AVAILABLE_DIRS.map((dir) => (
<DirectoryItem
key={dir}
dir={dir}
description={DIR_DESCRIPTIONS[dir]}
selected={selectedDirs.includes(dir)}
onToggle={() => toggleDir(dir)}
/>
))}
</div>
{/* Summary */}
{selectedDirs.length > 0 && (
<div className="pt-2 border-t border-border">
<p className="text-sm text-muted-foreground">
{formatMessage(
{ id: 'configSync.modal.selectedCount' },
{ count: selectedDirs.length }
) || `${selectedDirs.length} director${selectedDirs.length > 1 ? 'ies' : 'y'} selected`}
</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{formatMessage({ id: 'common.actions.cancel' }) || 'Cancel'}
</Button>
<Button onClick={onClose}>
{formatMessage({ id: 'common.actions.confirm' }) || 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ConfigSyncModal;

View File

@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { Badge } from '../ui/badge';
import { toast } from 'sonner';
import { VersionCheckModal } from './VersionCheckModal';
interface VersionData {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
}
interface VersionCheckResponse {
success: boolean;
data?: VersionData;
error?: string;
}
/**
* Safely parse localStorage item
* Returns default value if parsing fails
*/
function safeParseLocalStorage<T>(key: string, defaultValue: T): T {
try {
const saved = localStorage.getItem(key);
if (saved === null) return defaultValue;
return JSON.parse(saved) as T;
} catch {
// Corrupted data, remove and return default
localStorage.removeItem(key);
return defaultValue;
}
}
export function VersionCheck() {
const [versionData, setVersionData] = useState<VersionData | null>(null);
const [autoCheck, setAutoCheck] = useState(() => {
return safeParseLocalStorage('ccw.autoUpdate', true);
});
const [checking, setChecking] = useState(false);
const [showModal, setShowModal] = useState(false);
const checkVersion = async (silent = false) => {
if (!silent) setChecking(true);
try {
const response = await fetch('/api/config/version');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: VersionCheckResponse = await response.json();
// Validate response structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format');
}
if (!data.success) {
throw new Error(data.error || 'Version check failed');
}
if (!data.data || typeof data.data !== 'object') {
throw new Error('Invalid version data in response');
}
setVersionData(data.data);
if (data.data.updateAvailable && !silent) {
toast.info('新版本可用: ' + data.data.latestVersion);
}
} catch (error) {
console.error('Version check failed:', error);
if (!silent) {
toast.error('版本检查失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
} finally {
setChecking(false);
}
};
// Auto-check on mount and interval
useEffect(() => {
if (!autoCheck) return;
// Initial check after 2 seconds delay
const timer = setTimeout(() => checkVersion(true), 2000);
// Periodic check every hour
const interval = setInterval(() => checkVersion(true), 60 * 60 * 1000);
return () => {
clearTimeout(timer);
clearInterval(interval);
};
}, [autoCheck]);
const toggleAutoCheck = (enabled: boolean) => {
setAutoCheck(enabled);
localStorage.setItem('ccw.autoUpdate', JSON.stringify(enabled));
};
if (!versionData?.updateAvailable) {
return null; // Don't show anything if no update
}
return (
<>
<Badge
variant="warning"
className="cursor-pointer"
onClick={() => setShowModal(true)}
>
</Badge>
<button
onClick={() => checkVersion()}
disabled={checking}
className="ml-2 px-2 py-1 text-sm"
>
{checking ? '检查中...' : '立即检查'}
</button>
<label className="ml-4 flex items-center gap-2">
<input
type="checkbox"
checked={autoCheck}
onChange={(e) => toggleAutoCheck(e.target.checked)}
/>
<span className="text-sm"></span>
</label>
{showModal && (
<VersionCheckModal
currentVersion={versionData.currentVersion}
latestVersion={versionData.latestVersion}
onClose={() => setShowModal(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,59 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
interface VersionCheckModalProps {
currentVersion: string;
latestVersion: string;
onClose: () => void;
}
export function VersionCheckModal({ currentVersion, latestVersion, onClose }: VersionCheckModalProps) {
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono text-green-600">{latestVersion}</span>
</div>
<div className="bg-muted p-4 rounded">
<h4 className="font-medium mb-2"></h4>
<p className="text-sm text-muted-foreground">
访 GitHub Releases
</p>
</div>
<div className="flex gap-2">
<a
href="https://github.com/dyw0830/ccw/releases"
target="_blank"
rel="noopener noreferrer"
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded hover:opacity-90"
>
</a>
<button
onClick={onClose}
className="flex-1 px-4 py-2 border rounded hover:bg-muted"
>
</button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -146,3 +146,13 @@ export type { ExplorerToolbarProps } from './ExplorerToolbar';
// Ticker components
export { TickerMarquee } from './TickerMarquee';
export type { TickerMarqueeProps } from './TickerMarquee';
// Version check components
export { VersionCheck } from './VersionCheck';
export { VersionCheckModal } from './VersionCheckModal';
// Config sync components
export { ConfigSync } from './ConfigSync';
export { ConfigSyncModal } from './ConfigSyncModal';
export type { ConfigSyncProps, BackupInfo, SyncResult, BackupResult } from './ConfigSync';
export type { ConfigSyncModalProps } from './ConfigSyncModal';