mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
- 重写 escapeWindowsArg 函数,正确处理反斜杠和引号转义 - 添加 escapeUnixArg 函数支持 Linux/macOS shell 转义 - 添加 normalizePathSeparators 函数自动转换路径分隔符 - 修复 vscode-lsp.ts 中的 TypeScript 类型错误
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { resolve, join, relative, isAbsolute } from 'path';
|
|
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import { StoragePaths, ensureStorageDir, LegacyPaths } from '../config/storage-paths.js';
|
|
|
|
/**
|
|
* Validation result for path operations
|
|
*/
|
|
export interface PathValidationResult {
|
|
valid: boolean;
|
|
path: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* Options for path validation
|
|
*/
|
|
export interface ValidatePathOptions {
|
|
baseDir?: string | null;
|
|
mustExist?: boolean;
|
|
allowHome?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Normalize path separators to the native format for the current platform
|
|
* - Windows: converts / to \
|
|
* - Linux/macOS: converts \ to /
|
|
* @param inputPath - Path with potentially mixed separators
|
|
* @returns Path with native separators
|
|
*/
|
|
export function normalizePathSeparators(inputPath: string): string {
|
|
if (!inputPath) return inputPath;
|
|
|
|
if (process.platform === 'win32') {
|
|
// Windows: convert forward slashes to backslashes
|
|
return inputPath.replace(/\//g, '\\');
|
|
} else {
|
|
// Linux/macOS: convert backslashes to forward slashes
|
|
// This handles Windows-style paths being used on Unix systems
|
|
return inputPath.replace(/\\/g, '/');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a path, handling ~ for home directory
|
|
* Also handles Windows drive-relative paths (e.g., "D:path" -> "D:\path")
|
|
* and normalizes mixed slashes for cross-platform compatibility
|
|
*
|
|
* Cross-platform behavior:
|
|
* - Windows: D:/path/to/file -> D:\path\to\file
|
|
* - Linux/macOS: /path\to/file -> /path/to/file
|
|
*
|
|
* @param inputPath - Path to resolve (can use / or \ on any platform)
|
|
* @returns Absolute path with native separators
|
|
*/
|
|
export function resolvePath(inputPath: string): string {
|
|
if (!inputPath) return process.cwd();
|
|
|
|
// Handle ~ for home directory (before normalizing separators)
|
|
if (inputPath.startsWith('~')) {
|
|
const remainder = inputPath.slice(1);
|
|
return join(homedir(), normalizePathSeparators(remainder));
|
|
}
|
|
|
|
// Normalize path separators to native format
|
|
inputPath = normalizePathSeparators(inputPath);
|
|
|
|
// Handle Windows drive-relative paths (e.g., "D:path" without backslash)
|
|
// Pattern: single letter followed by colon, then immediately a non-slash character
|
|
// This converts "D:path" to "D:\path" to make it absolute
|
|
// Only apply on Windows or when path looks like a Windows drive path
|
|
if (process.platform === 'win32') {
|
|
const driveRelativeMatch = inputPath.match(/^([a-zA-Z]:)([^\\].*)$/);
|
|
if (driveRelativeMatch) {
|
|
// Insert backslash after drive letter
|
|
inputPath = driveRelativeMatch[1] + '\\' + driveRelativeMatch[2];
|
|
}
|
|
}
|
|
|
|
return resolve(inputPath);
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize a user-provided path
|
|
* Prevents path traversal attacks and validates path is within allowed boundaries
|
|
* @param inputPath - User-provided path
|
|
* @param options - Validation options
|
|
* @returns Validation result with path or error
|
|
*/
|
|
export function validatePath(inputPath: string, options: ValidatePathOptions = {}): PathValidationResult {
|
|
const { baseDir = null, mustExist = false, allowHome = true } = options;
|
|
|
|
// Check for empty/null input
|
|
if (!inputPath || typeof inputPath !== 'string') {
|
|
return { valid: false, path: null, error: 'Path is required' };
|
|
}
|
|
|
|
// Trim whitespace
|
|
const trimmedPath = inputPath.trim();
|
|
|
|
// Check for suspicious patterns (null bytes, control characters)
|
|
if (/[\x00-\x1f]/.test(trimmedPath)) {
|
|
return { valid: false, path: null, error: 'Path contains invalid characters' };
|
|
}
|
|
|
|
// Resolve the path
|
|
let resolvedPath: string;
|
|
try {
|
|
resolvedPath = resolvePath(trimmedPath);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { valid: false, path: null, error: `Invalid path: ${message}` };
|
|
}
|
|
|
|
// Check if within base directory when specified (pre-symlink resolution)
|
|
const resolvedBase = baseDir ? resolvePath(baseDir) : null;
|
|
if (resolvedBase) {
|
|
const relativePath = relative(resolvedBase, resolvedPath);
|
|
|
|
// Path traversal detection: relative path should not start with '..'
|
|
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
return {
|
|
valid: false,
|
|
path: null,
|
|
error: `Path must be within ${resolvedBase}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check if path exists when required
|
|
if (mustExist && !existsSync(resolvedPath)) {
|
|
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
|
|
}
|
|
|
|
// Get real path if it exists (resolves symlinks)
|
|
let realPath = resolvedPath;
|
|
if (existsSync(resolvedPath)) {
|
|
try {
|
|
realPath = realpathSync(resolvedPath);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { valid: false, path: null, error: `Cannot resolve path: ${message}` };
|
|
}
|
|
} else if (resolvedBase) {
|
|
// For non-existent paths, resolve the nearest existing ancestor to prevent symlink-based escapes
|
|
// (e.g., baseDir/link/newfile where baseDir/link is a symlink to a disallowed location).
|
|
let existingPath = resolvedPath;
|
|
while (!existsSync(existingPath)) {
|
|
const parent = resolve(existingPath, '..');
|
|
if (parent === existingPath) break;
|
|
existingPath = parent;
|
|
}
|
|
|
|
if (existsSync(existingPath)) {
|
|
try {
|
|
const realExisting = realpathSync(existingPath);
|
|
const remainder = relative(existingPath, resolvedPath);
|
|
realPath = remainder && remainder !== '.' ? join(realExisting, remainder) : realExisting;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { valid: false, path: null, error: `Cannot resolve path: ${message}` };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if within base directory when specified (post-symlink resolution)
|
|
if (resolvedBase) {
|
|
const relativePath = relative(resolvedBase, realPath);
|
|
|
|
// Path traversal detection: relative path should not start with '..'
|
|
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
return {
|
|
valid: false,
|
|
path: null,
|
|
error: `Path must be within ${resolvedBase}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check home directory restriction
|
|
if (!allowHome) {
|
|
const home = homedir();
|
|
if (realPath === home || realPath.startsWith(home + '/') || realPath.startsWith(home + '\\')) {
|
|
// This is fine, we're just checking if it's explicitly the home dir itself
|
|
}
|
|
}
|
|
|
|
return { valid: true, path: realPath, error: null };
|
|
}
|
|
|
|
/**
|
|
* Validate output file path for writing
|
|
* @param outputPath - Output file path
|
|
* @param defaultDir - Default directory if path is relative
|
|
* @returns Validation result with path or error
|
|
*/
|
|
export function validateOutputPath(outputPath: string, defaultDir: string = process.cwd()): PathValidationResult {
|
|
if (!outputPath || typeof outputPath !== 'string') {
|
|
return { valid: false, path: null, error: 'Output path is required' };
|
|
}
|
|
|
|
const trimmedPath = outputPath.trim();
|
|
|
|
// Check for suspicious patterns
|
|
if (/[\x00-\x1f]/.test(trimmedPath)) {
|
|
return { valid: false, path: null, error: 'Output path contains invalid characters' };
|
|
}
|
|
|
|
// Resolve the path
|
|
let resolvedPath: string;
|
|
try {
|
|
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
|
|
resolvedPath = resolve(resolvedPath);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { valid: false, path: null, error: `Invalid output path: ${message}` };
|
|
}
|
|
|
|
// Ensure it's not a directory
|
|
if (existsSync(resolvedPath)) {
|
|
try {
|
|
const stat = statSync(resolvedPath);
|
|
if (stat.isDirectory()) {
|
|
return { valid: false, path: null, error: 'Output path is a directory, expected a file' };
|
|
}
|
|
} catch {
|
|
// Ignore stat errors
|
|
}
|
|
}
|
|
|
|
return { valid: true, path: resolvedPath, error: null };
|
|
}
|
|
|
|
/**
|
|
* Get potential template locations
|
|
* @returns Array of existing template directories
|
|
*/
|
|
export function getTemplateLocations(): string[] {
|
|
const locations = [
|
|
join(homedir(), '.claude', 'templates'),
|
|
join(process.cwd(), '.claude', 'templates')
|
|
];
|
|
|
|
return locations.filter(loc => existsSync(loc));
|
|
}
|
|
|
|
/**
|
|
* Find a template file in known locations
|
|
* @param templateName - Name of template file (e.g., 'workflow-dashboard.html')
|
|
* @returns Path to template or null if not found
|
|
*/
|
|
export function findTemplate(templateName: string): string | null {
|
|
const locations = getTemplateLocations();
|
|
|
|
for (const loc of locations) {
|
|
const templatePath = join(loc, templateName);
|
|
if (existsSync(templatePath)) {
|
|
return templatePath;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ensure directory exists, creating if necessary
|
|
* @param dirPath - Directory path to ensure
|
|
*/
|
|
export function ensureDir(dirPath: string): void {
|
|
if (!existsSync(dirPath)) {
|
|
mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize path for display (handle Windows backslashes)
|
|
* @param filePath - Path to normalize
|
|
* @returns Normalized path with forward slashes
|
|
*/
|
|
export function normalizePathForDisplay(filePath: string): string {
|
|
return filePath.replace(/\\/g, '/');
|
|
}
|
|
|
|
// Recent paths storage - uses centralized storage with backward compatibility
|
|
const MAX_RECENT_PATHS = 10;
|
|
|
|
/**
|
|
* Get the recent paths file location
|
|
* Uses new location but falls back to legacy location for backward compatibility
|
|
*/
|
|
function getRecentPathsFile(): string {
|
|
const newPath = StoragePaths.global.recentPaths();
|
|
const legacyPath = LegacyPaths.recentPaths();
|
|
|
|
// Backward compatibility: use legacy if it exists and new doesn't
|
|
if (!existsSync(newPath) && existsSync(legacyPath)) {
|
|
return legacyPath;
|
|
}
|
|
return newPath;
|
|
}
|
|
|
|
/**
|
|
* Recent paths data structure
|
|
*/
|
|
interface RecentPathsData {
|
|
paths: string[];
|
|
}
|
|
|
|
/**
|
|
* Get recent project paths
|
|
* @returns Array of recent paths
|
|
*/
|
|
export function getRecentPaths(): string[] {
|
|
try {
|
|
const recentPathsFile = getRecentPathsFile();
|
|
if (existsSync(recentPathsFile)) {
|
|
const content = readFileSync(recentPathsFile, 'utf8');
|
|
const data = JSON.parse(content) as RecentPathsData;
|
|
return Array.isArray(data.paths) ? data.paths : [];
|
|
}
|
|
} catch {
|
|
// Ignore errors, return empty array
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Track a project path (add to recent paths)
|
|
* @param projectPath - Path to track
|
|
*/
|
|
export function trackRecentPath(projectPath: string): void {
|
|
try {
|
|
const normalized = normalizePathForDisplay(resolvePath(projectPath));
|
|
let paths = getRecentPaths();
|
|
|
|
// Remove if already exists (will be added to front)
|
|
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
|
|
|
|
// Add to front
|
|
paths.unshift(normalized);
|
|
|
|
// Limit to max
|
|
paths = paths.slice(0, MAX_RECENT_PATHS);
|
|
|
|
// Save to new centralized location
|
|
const recentPathsFile = StoragePaths.global.recentPaths();
|
|
ensureStorageDir(StoragePaths.global.config());
|
|
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear recent paths
|
|
*/
|
|
export function clearRecentPaths(): void {
|
|
try {
|
|
const recentPathsFile = StoragePaths.global.recentPaths();
|
|
ensureStorageDir(StoragePaths.global.config());
|
|
writeFileSync(recentPathsFile, JSON.stringify({ paths: [] }, null, 2), 'utf8');
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a specific path from recent paths
|
|
* @param pathToRemove - Path to remove
|
|
* @returns True if removed, false if not found
|
|
*/
|
|
export function removeRecentPath(pathToRemove: string): boolean {
|
|
try {
|
|
const normalized = normalizePathForDisplay(resolvePath(pathToRemove));
|
|
let paths = getRecentPaths();
|
|
const originalLength = paths.length;
|
|
|
|
// Filter out the path to remove
|
|
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
|
|
|
|
if (paths.length < originalLength) {
|
|
// Save updated list to new centralized location
|
|
const recentPathsFile = StoragePaths.global.recentPaths();
|
|
ensureStorageDir(StoragePaths.global.config());
|
|
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|