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:
catlog22
2025-12-04 09:40:12 +08:00
parent 0f9adc59f9
commit 35bd0aa8f6
22 changed files with 8272 additions and 24 deletions

View 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
);
}

View 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);
}

View 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
View 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)));
}