mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
231
ccw/src/utils/docs-frontend.ts
Normal file
231
ccw/src/utils/docs-frontend.ts
Normal 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
|
||||
};
|
||||
}
|
||||
137
ccw/src/utils/security-validation.ts
Normal file
137
ccw/src/utils/security-validation.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user