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,231 @@
import { spawn, type ChildProcess } from 'child_process';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import chalk from 'chalk';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let docsProcess: ChildProcess | null = null;
let docsPort: number | null = null;
// Default Docusaurus port
const DEFAULT_DOCS_PORT = 3001;
/**
* Start Docusaurus documentation development server
* @param port - Port to run Docusaurus server on (default: 3001)
* @returns Promise that resolves when server is ready
*/
export async function startDocsSite(port: number = DEFAULT_DOCS_PORT): Promise<void> {
// Check if already running
if (docsProcess && docsPort === port) {
console.log(chalk.yellow(` Docs site already running on port ${port}`));
return;
}
// Try to find docs-site directory (relative to ccw package)
const possiblePaths = [
join(__dirname, '../../docs-site'), // From dist/utils
join(__dirname, '../docs-site'), // From src/utils (dev)
join(process.cwd(), 'docs-site'), // Current working directory
];
let docsDir: string | null = null;
for (const path of possiblePaths) {
const resolvedPath = resolve(path);
try {
const { existsSync } = await import('fs');
if (existsSync(resolvedPath)) {
docsDir = resolvedPath;
break;
}
} catch {
// Continue to next path
}
}
if (!docsDir) {
console.log(chalk.yellow(` Docs site directory not found. Skipping docs server startup.`));
console.log(chalk.gray(` The /docs endpoint will not be available.`));
return;
}
console.log(chalk.cyan(` Starting Docusaurus docs site on port ${port}...`));
console.log(chalk.gray(` Docs dir: ${docsDir}`));
// Check if package.json exists and has start script
const packageJsonPath = join(docsDir, 'package.json');
try {
const { readFileSync, existsSync } = await import('fs');
if (!existsSync(packageJsonPath)) {
throw new Error('package.json not found in docs-site directory');
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
if (!packageJson.scripts?.start) {
throw new Error('No "start" script found in package.json');
}
} catch (error) {
console.log(chalk.yellow(` Failed to validate docs-site setup: ${error}`));
console.log(chalk.gray(` Skipping docs server startup.`));
return;
}
// Spawn Docusaurus dev server
// Use npm run start with PORT environment variable for cross-platform compatibility
// On Windows with shell: true, we need to pass arguments differently
const cmd = process.platform === 'win32'
? `npm start`
: `npm start`;
docsProcess = spawn(cmd, [], {
cwd: docsDir,
stdio: 'pipe',
shell: true,
env: {
...process.env,
// Set PORT via environment variable (Docusaurus respects this)
PORT: port.toString(),
HOST: 'localhost',
// Docusaurus uses COLUMNS for terminal width
COLUMNS: '80',
}
});
docsPort = port;
// Wait for server to be ready
return new Promise((resolve, reject) => {
let output = '';
let errorOutput = '';
const timeout = setTimeout(() => {
docsProcess?.kill();
reject(new Error(
`Docs site startup timeout (60s).\n` +
`Output: ${output}\n` +
`Errors: ${errorOutput}`
));
}, 60000); // Docusaurus can take longer to start
const cleanup = () => {
clearTimeout(timeout);
docsProcess?.stdout?.removeAllListeners();
docsProcess?.stderr?.removeAllListeners();
};
docsProcess?.stdout?.on('data', (data: Buffer) => {
const chunk = data.toString();
output += chunk;
// Log all Docusaurus output for debugging
console.log(chalk.gray(` Docs: ${chunk.trim()}`));
// Check for ready signals (Docusaurus output format)
if (
chunk.includes('Compiled successfully') ||
chunk.includes('Compiled with warnings') ||
chunk.includes('The server is running at') ||
chunk.includes(`http://localhost:${port}`) ||
(chunk.includes('Docusaurus') && (chunk.includes('started') || chunk.includes('ready'))) ||
chunk.includes('➜') || // Docusaurus uses this in CLI output
chunk.includes('Local:')
) {
cleanup();
console.log(chalk.green(` Docs site ready at http://localhost:${port}/docs/`));
resolve();
}
});
docsProcess?.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString();
errorOutput += chunk;
// Log warnings but don't fail
if (chunk.toLowerCase().includes('warn') || chunk.toLowerCase().includes('warning')) {
console.log(chalk.yellow(` Docs: ${chunk.trim()}`));
}
});
docsProcess?.on('error', (err: Error) => {
cleanup();
reject(err);
});
docsProcess?.on('exit', (code: number | null) => {
if (code !== 0 && code !== null) {
cleanup();
reject(new Error(`Docs process exited with code ${code}. Errors: ${errorOutput}`));
}
});
});
}
/**
* Stop Docusaurus documentation development server
*/
export async function stopDocsSite(): Promise<void> {
if (docsProcess) {
console.log(chalk.yellow(' Stopping docs site...'));
// Try graceful shutdown first
docsProcess.kill('SIGTERM');
// Wait up to 5 seconds for graceful shutdown
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve();
}, 5000);
docsProcess?.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
// Force kill if still running
if (docsProcess && !docsProcess.killed) {
// On Windows with shell: true, we need to kill the entire process group
if (process.platform === 'win32') {
try {
// Use taskkill to forcefully terminate the process tree
const { exec } = await import('child_process');
const pid = docsProcess.pid;
if (pid) {
await new Promise<void>((resolve) => {
exec(`taskkill /F /T /PID ${pid}`, (err) => {
if (err) {
// Fallback to SIGKILL if taskkill fails
docsProcess?.kill('SIGKILL');
}
resolve();
});
});
}
} catch {
// Fallback to SIGKILL
docsProcess.kill('SIGKILL');
}
} else {
docsProcess.kill('SIGKILL');
}
}
// Wait a bit more for force kill to complete
await new Promise(resolve => setTimeout(resolve, 500));
docsProcess = null;
docsPort = null;
}
}
/**
* Get docs site status
* @returns Object with running status and port
*/
export function getDocsSiteStatus(): { running: boolean; port: number | null } {
return {
running: docsProcess !== null && !docsProcess.killed,
port: docsPort
};
}

View File

@@ -0,0 +1,137 @@
/**
* Security utilities for input validation
* Provides validation functions to prevent common security vulnerabilities
*/
/**
* Valid config directory names (whitelist approach)
*/
export const VALID_CONFIG_DIRS = ['.claude', '.codex', '.gemini', '.qwen'] as const;
/**
* Valid config directory name type
*/
export type ValidConfigDir = typeof VALID_CONFIG_DIRS[number];
/**
* Check if a string is a valid config directory name
* Uses whitelist approach for security
*/
export function isValidConfigDirName(name: string): boolean {
// Type guard to ensure name is a string
if (typeof name !== 'string') return false;
// Must start with dot
if (!name.startsWith('.')) return false;
// Must be in whitelist
return VALID_CONFIG_DIRS.includes(name as any);
}
/**
* Check if a string is a valid backup name
* Prevents path traversal attacks
*/
export function isValidBackupName(name: string): boolean {
// Type guard
if (typeof name !== 'string') return false;
// Prevent path traversal
if (name.includes('..')) return false;
if (name.includes('/') || name.includes('\\')) return false;
// Prevent null bytes
if (name.includes('\0')) return false;
// Only allow alphanumeric, hyphen, underscore, dot
const regex = /^[a-zA-Z0-9._-]+$/;
return regex.test(name) && name.length > 0 && name.length <= 100;
}
/**
* Check if a string is a valid GitHub repository identifier
* GitHub repo rules: max 100 chars, alphanumeric, hyphen, underscore, dot
* Cannot start or end with hyphen
*/
export function isValidGitHubIdentifier(name: string): boolean {
// Type guard
if (typeof name !== 'string') return false;
// Length limit
if (name.length === 0 || name.length > 100) return false;
// Cannot start or end with hyphen
if (name.startsWith('-') || name.endsWith('-')) return false;
// Only allowed characters
const regex = /^[a-zA-Z0-9._-]+$/;
return regex.test(name);
}
/**
* Check if a string is a valid Git branch name
* Git branch rules: cannot begin with a dot, cannot contain .. or ~, cannot end with .lock
*/
export function isValidBranchName(name: string): boolean {
// Type guard
if (typeof name !== 'string') return false;
// Cannot be empty
if (name.length === 0) return false;
// Cannot begin with a dot
if (name.startsWith('.')) return false;
// Cannot contain .. or ~ or :
if (name.includes('..') || name.includes('~') || name.includes(':')) return false;
// Cannot end with .lock
if (name.endsWith('.lock')) return false;
// Only allow safe characters (alphanumeric, hyphen, underscore, dot, slash)
const regex = /^[a-zA-Z0-9_./-]+$/;
return regex.test(name);
}
/**
* Validate an array of config directory names
* Throws error if any invalid
*/
export function validateConfigDirs(dirs: string[]): void {
if (!Array.isArray(dirs)) {
throw new Error('configDirs must be an array');
}
for (const dir of dirs) {
if (!isValidConfigDirName(dir)) {
throw new Error(
`Invalid config directory: "${dir}". ` +
`Valid options are: ${VALID_CONFIG_DIRS.join(', ')}`
);
}
}
}
/**
* Validate GitHub sync parameters
* Throws error if any invalid
*/
export function validateGitHubParams(params: {
owner?: string;
repo?: string;
branch?: string;
}): void {
const { owner, repo, branch } = params;
if (owner !== undefined && !isValidGitHubIdentifier(owner)) {
throw new Error(`Invalid GitHub owner identifier: "${owner}"`);
}
if (repo !== undefined && !isValidGitHubIdentifier(repo)) {
throw new Error(`Invalid GitHub repository name: "${repo}"`);
}
if (branch !== undefined && !isValidBranchName(branch)) {
throw new Error(`Invalid branch name: "${branch}"`);
}
}