mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat: Add workflow dashboard template and utility functions
- Implemented a new HTML template for the workflow dashboard, featuring a responsive design with dark/light theme support, session statistics, and task management UI. - Created a browser launcher utility to open HTML files in the default browser across platforms. - Developed file utility functions for safe reading and writing of JSON and text files. - Added path resolver utilities to validate and resolve file paths, ensuring security against path traversal attacks. - Introduced UI utilities for displaying styled messages and banners in the console.
This commit is contained in:
49
ccw/src/utils/browser-launcher.js
Normal file
49
ccw/src/utils/browser-launcher.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import open from 'open';
|
||||
import { platform } from 'os';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Launch a file in the default browser
|
||||
* Cross-platform compatible (Windows/macOS/Linux)
|
||||
* @param {string} filePath - Path to HTML file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function launchBrowser(filePath) {
|
||||
const absolutePath = resolve(filePath);
|
||||
|
||||
// Construct file:// URL based on platform
|
||||
let url;
|
||||
if (platform() === 'win32') {
|
||||
// Windows: file:///C:/path/to/file.html
|
||||
url = `file:///${absolutePath.replace(/\\/g, '/')}`;
|
||||
} else {
|
||||
// Unix: file:///path/to/file.html
|
||||
url = `file://${absolutePath}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the 'open' package which handles cross-platform browser launching
|
||||
await open(url);
|
||||
} catch (error) {
|
||||
// Fallback: try opening the file path directly
|
||||
try {
|
||||
await open(absolutePath);
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to open browser: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running in a headless/CI environment
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isHeadlessEnvironment() {
|
||||
return !!(
|
||||
process.env.CI ||
|
||||
process.env.CONTINUOUS_INTEGRATION ||
|
||||
process.env.GITHUB_ACTIONS ||
|
||||
process.env.GITLAB_CI ||
|
||||
process.env.JENKINS_URL
|
||||
);
|
||||
}
|
||||
48
ccw/src/utils/file-utils.js
Normal file
48
ccw/src/utils/file-utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Safely read a JSON file
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @returns {Object|null} - Parsed JSON or null on error
|
||||
*/
|
||||
export function readJsonFile(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read a text file
|
||||
* @param {string} filePath - Path to text file
|
||||
* @returns {string|null} - File contents or null on error
|
||||
*/
|
||||
export function readTextFile(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file
|
||||
* @param {string} filePath - Path to file
|
||||
* @param {string} content - Content to write
|
||||
*/
|
||||
export function writeTextFile(filePath, content) {
|
||||
writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pathExists(filePath) {
|
||||
return existsSync(filePath);
|
||||
}
|
||||
195
ccw/src/utils/path-resolver.js
Normal file
195
ccw/src/utils/path-resolver.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import { resolve, join, relative, isAbsolute } from 'path';
|
||||
import { existsSync, mkdirSync, realpathSync, statSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Resolve a path, handling ~ for home directory
|
||||
* @param {string} inputPath - Path to resolve
|
||||
* @returns {string} - Absolute path
|
||||
*/
|
||||
export function resolvePath(inputPath) {
|
||||
if (!inputPath) return process.cwd();
|
||||
|
||||
// Handle ~ for home directory
|
||||
if (inputPath.startsWith('~')) {
|
||||
return join(homedir(), inputPath.slice(1));
|
||||
}
|
||||
|
||||
return resolve(inputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize a user-provided path
|
||||
* Prevents path traversal attacks and validates path is within allowed boundaries
|
||||
* @param {string} inputPath - User-provided path
|
||||
* @param {Object} options - Validation options
|
||||
* @param {string} options.baseDir - Base directory to restrict paths within (optional)
|
||||
* @param {boolean} options.mustExist - Whether path must exist (default: false)
|
||||
* @param {boolean} options.allowHome - Whether to allow home directory paths (default: true)
|
||||
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
|
||||
*/
|
||||
export function validatePath(inputPath, options = {}) {
|
||||
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;
|
||||
try {
|
||||
resolvedPath = resolvePath(trimmedPath);
|
||||
} catch (err) {
|
||||
return { valid: false, path: null, error: `Invalid path: ${err.message}` };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return { valid: false, path: null, error: `Cannot resolve path: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if within base directory when specified
|
||||
if (baseDir) {
|
||||
const resolvedBase = resolvePath(baseDir);
|
||||
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 {string} outputPath - Output file path
|
||||
* @param {string} defaultDir - Default directory if path is relative
|
||||
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
|
||||
*/
|
||||
export function validateOutputPath(outputPath, defaultDir = process.cwd()) {
|
||||
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;
|
||||
try {
|
||||
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
|
||||
resolvedPath = resolve(resolvedPath);
|
||||
} catch (err) {
|
||||
return { valid: false, path: null, error: `Invalid output path: ${err.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 {string[]} - Array of existing template directories
|
||||
*/
|
||||
export function getTemplateLocations() {
|
||||
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 {string} templateName - Name of template file (e.g., 'workflow-dashboard.html')
|
||||
* @returns {string|null} - Path to template or null if not found
|
||||
*/
|
||||
export function findTemplate(templateName) {
|
||||
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 {string} dirPath - Directory path to ensure
|
||||
*/
|
||||
export function ensureDir(dirPath) {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the .workflow directory path from project path
|
||||
* @param {string} projectPath - Path to project
|
||||
* @returns {string} - Path to .workflow directory
|
||||
*/
|
||||
export function getWorkflowDir(projectPath) {
|
||||
return join(resolvePath(projectPath), '.workflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for display (handle Windows backslashes)
|
||||
* @param {string} filePath - Path to normalize
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizePathForDisplay(filePath) {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
148
ccw/src/utils/ui.js
Normal file
148
ccw/src/utils/ui.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import chalk from 'chalk';
|
||||
import figlet from 'figlet';
|
||||
import boxen from 'boxen';
|
||||
import gradient from 'gradient-string';
|
||||
import ora from 'ora';
|
||||
|
||||
// Custom gradient colors
|
||||
const claudeGradient = gradient(['#00d4ff', '#00ff88']);
|
||||
const codeGradient = gradient(['#00ff88', '#ffff00']);
|
||||
const workflowGradient = gradient(['#ffff00', '#ff8800']);
|
||||
|
||||
/**
|
||||
* Display ASCII art banner
|
||||
*/
|
||||
export function showBanner() {
|
||||
console.log('');
|
||||
|
||||
// CLAUDE in cyan gradient
|
||||
try {
|
||||
const claudeText = figlet.textSync('Claude', { font: 'Standard' });
|
||||
console.log(claudeGradient(claudeText));
|
||||
} catch {
|
||||
console.log(chalk.cyan.bold(' Claude'));
|
||||
}
|
||||
|
||||
// CODE in green gradient
|
||||
try {
|
||||
const codeText = figlet.textSync('Code', { font: 'Standard' });
|
||||
console.log(codeGradient(codeText));
|
||||
} catch {
|
||||
console.log(chalk.green.bold(' Code'));
|
||||
}
|
||||
|
||||
// WORKFLOW in yellow gradient
|
||||
try {
|
||||
const workflowText = figlet.textSync('Workflow', { font: 'Standard' });
|
||||
console.log(workflowGradient(workflowText));
|
||||
} catch {
|
||||
console.log(chalk.yellow.bold(' Workflow'));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display header with version info
|
||||
* @param {string} version - Version number
|
||||
* @param {string} mode - Installation mode
|
||||
*/
|
||||
export function showHeader(version, mode = '') {
|
||||
showBanner();
|
||||
|
||||
const versionText = version ? `v${version}` : '';
|
||||
const modeText = mode ? ` (${mode})` : '';
|
||||
|
||||
console.log(boxen(
|
||||
chalk.cyan.bold('Claude Code Workflow System') + '\n' +
|
||||
chalk.gray(`Installer ${versionText}${modeText}`) + '\n\n' +
|
||||
chalk.white('Unified workflow system with comprehensive coordination'),
|
||||
{
|
||||
padding: 1,
|
||||
margin: { top: 0, bottom: 1, left: 2, right: 2 },
|
||||
borderStyle: 'round',
|
||||
borderColor: 'cyan'
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spinner
|
||||
* @param {string} text - Spinner text
|
||||
* @returns {ora.Ora}
|
||||
*/
|
||||
export function createSpinner(text) {
|
||||
return ora({
|
||||
text,
|
||||
color: 'cyan',
|
||||
spinner: 'dots'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display success message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function success(message) {
|
||||
console.log(chalk.green('✓') + ' ' + chalk.green(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display info message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function info(message) {
|
||||
console.log(chalk.cyan('ℹ') + ' ' + chalk.cyan(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display warning message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function warning(message) {
|
||||
console.log(chalk.yellow('⚠') + ' ' + chalk.yellow(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function error(message) {
|
||||
console.log(chalk.red('✖') + ' ' + chalk.red(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display step message
|
||||
* @param {number} step - Step number
|
||||
* @param {number} total - Total steps
|
||||
* @param {string} message - Step message
|
||||
*/
|
||||
export function step(stepNum, total, message) {
|
||||
console.log(chalk.gray(`[${stepNum}/${total}]`) + ' ' + chalk.white(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display summary box
|
||||
* @param {Object} options
|
||||
* @param {string} options.title - Box title
|
||||
* @param {string[]} options.lines - Content lines
|
||||
* @param {string} options.borderColor - Border color
|
||||
*/
|
||||
export function summaryBox({ title, lines, borderColor = 'green' }) {
|
||||
const content = lines.join('\n');
|
||||
console.log(boxen(content, {
|
||||
title,
|
||||
titleAlignment: 'center',
|
||||
padding: 1,
|
||||
margin: { top: 1, bottom: 1, left: 2, right: 2 },
|
||||
borderStyle: 'round',
|
||||
borderColor
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a divider line
|
||||
*/
|
||||
export function divider() {
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
}
|
||||
Reference in New Issue
Block a user