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

309
ccw/src/commands/install.js Normal file
View File

@@ -0,0 +1,309 @@
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
import { join, dirname, basename, relative } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showHeader, showBanner, createSpinner, success, info, warning, error, summaryBox, step, divider } from '../utils/ui.js';
import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests } from '../core/manifest.js';
import { validatePath } from '../utils/path-resolver.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directories to install
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot() {
return join(__dirname, '..', '..');
}
// Get source installation directory (parent of ccw)
function getSourceDir() {
return join(getPackageRoot(), '..');
}
/**
* Install command handler
* @param {Object} options - Command options
*/
export async function installCommand(options) {
const version = getVersion();
// Show beautiful header
showHeader(version);
// Check for existing installations
const existingManifests = getAllManifests();
if (existingManifests.length > 0 && !options.force) {
info('Existing installations detected:');
console.log('');
existingManifests.forEach((m, i) => {
console.log(chalk.gray(` ${i + 1}. ${m.installation_mode} - ${m.installation_path}`));
console.log(chalk.gray(` Installed: ${new Date(m.installation_date).toLocaleDateString()}`));
});
console.log('');
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: 'Continue with new installation?',
default: true
}]);
if (!proceed) {
info('Installation cancelled');
return;
}
}
// Interactive mode selection
const mode = options.mode || await selectMode();
let installPath;
if (mode === 'Global') {
installPath = homedir();
info(`Global installation to: ${installPath}`);
} else {
const inputPath = options.path || await selectPath();
// Validate the installation path
const pathValidation = validatePath(inputPath, { mustExist: true });
if (!pathValidation.valid) {
error(`Invalid installation path: ${pathValidation.error}`);
process.exit(1);
}
installPath = pathValidation.path;
info(`Path installation to: ${installPath}`);
}
// Validate source directories exist
const sourceDir = getSourceDir();
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
if (availableDirs.length === 0) {
error('No source directories found to install.');
error(`Expected directories in: ${sourceDir}`);
process.exit(1);
}
console.log('');
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
divider();
// Check for existing installation at target path
const existingManifest = findManifest(installPath, mode);
if (existingManifest) {
warning('Existing installation found at this location');
const { backup } = await inquirer.prompt([{
type: 'confirm',
name: 'backup',
message: 'Create backup before reinstalling?',
default: true
}]);
if (backup) {
await createBackup(installPath, existingManifest);
}
}
// Create manifest
const manifest = createManifest(mode, installPath);
// Perform installation
console.log('');
const spinner = createSpinner('Installing files...').start();
let totalFiles = 0;
let totalDirs = 0;
try {
for (const dir of availableDirs) {
const srcPath = join(sourceDir, dir);
const destPath = join(installPath, dir);
spinner.text = `Installing ${dir}...`;
const { files, directories } = await copyDirectory(srcPath, destPath, manifest);
totalFiles += files;
totalDirs += directories;
}
// Create version.json
const versionPath = join(installPath, '.claude', 'version.json');
if (existsSync(dirname(versionPath))) {
const versionInfo = {
version: version,
installedAt: new Date().toISOString(),
mode: mode,
installer: 'ccw'
};
writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2), 'utf8');
addFileEntry(manifest, versionPath);
totalFiles++;
}
spinner.succeed('Installation complete!');
} catch (err) {
spinner.fail('Installation failed');
error(err.message);
process.exit(1);
}
// Save manifest
const manifestPath = saveManifest(manifest);
// Show summary
console.log('');
summaryBox({
title: ' Installation Summary ',
lines: [
chalk.green.bold('✓ Installation Successful'),
'',
chalk.white(`Mode: ${chalk.cyan(mode)}`),
chalk.white(`Path: ${chalk.cyan(installPath)}`),
'',
chalk.gray(`Files installed: ${totalFiles}`),
chalk.gray(`Directories created: ${totalDirs}`),
'',
chalk.gray(`Manifest: ${basename(manifestPath)}`),
],
borderColor: 'green'
});
// Show next steps
console.log('');
info('Next steps:');
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
console.log(chalk.gray(' 3. Run: ccw uninstall - to remove this installation'));
console.log('');
}
/**
* Interactive mode selection
* @returns {Promise<string>} - Selected mode
*/
async function selectMode() {
const { mode } = await inquirer.prompt([{
type: 'list',
name: 'mode',
message: 'Select installation mode:',
choices: [
{
name: `${chalk.cyan('Global')} - Install to home directory (recommended)`,
value: 'Global'
},
{
name: `${chalk.yellow('Path')} - Install to specific project path`,
value: 'Path'
}
]
}]);
return mode;
}
/**
* Interactive path selection
* @returns {Promise<string>} - Selected path
*/
async function selectPath() {
const { path } = await inquirer.prompt([{
type: 'input',
name: 'path',
message: 'Enter installation path:',
default: process.cwd(),
validate: (input) => {
if (!input) return 'Path is required';
if (!existsSync(input)) {
return `Path does not exist: ${input}`;
}
return true;
}
}]);
return path;
}
/**
* Create backup of existing installation
* @param {string} installPath - Installation path
* @param {Object} manifest - Existing manifest
*/
async function createBackup(installPath, manifest) {
const spinner = createSpinner('Creating backup...').start();
try {
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const backupDir = join(installPath, `.claude-backup-${timestamp}`);
mkdirSync(backupDir, { recursive: true });
// Copy existing .claude directory
const claudeDir = join(installPath, '.claude');
if (existsSync(claudeDir)) {
await copyDirectory(claudeDir, join(backupDir, '.claude'));
}
spinner.succeed(`Backup created: ${backupDir}`);
} catch (err) {
spinner.warn(`Backup failed: ${err.message}`);
}
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Object} manifest - Manifest to track files (optional)
* @returns {Object} - Count of files and directories
*/
async function copyDirectory(src, dest, manifest = null) {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
if (manifest) addDirectoryEntry(manifest, dest);
}
const entries = readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
const result = await copyDirectory(srcPath, destPath, manifest);
files += result.files;
directories += result.directories;
} else {
copyFileSync(srcPath, destPath);
files++;
if (manifest) addFileEntry(manifest, destPath);
}
}
return { files, directories };
}
/**
* Get package version
* @returns {string} - Version string
*/
function getVersion() {
try {
const pkgPath = join(getPackageRoot(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || '1.0.0';
} catch {
return '1.0.0';
}
}

37
ccw/src/commands/list.js Normal file
View File

@@ -0,0 +1,37 @@
import chalk from 'chalk';
import { showBanner, divider, info } from '../utils/ui.js';
import { getAllManifests } from '../core/manifest.js';
/**
* List command handler - shows all installations
*/
export async function listCommand() {
showBanner();
console.log(chalk.cyan.bold(' Installed Claude Code Workflow Instances\n'));
const manifests = getAllManifests();
if (manifests.length === 0) {
info('No installations found.');
console.log('');
console.log(chalk.gray(' Run: ccw install - to install Claude Code Workflow'));
console.log('');
return;
}
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white.bold(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count}`));
console.log(chalk.gray(` Dirs: ${m.directories_count}`));
console.log('');
});
divider();
console.log(chalk.gray(' Run: ccw uninstall - to remove an installation'));
console.log('');
}

View File

@@ -0,0 +1,238 @@
import { existsSync, unlinkSync, rmdirSync, readdirSync, statSync } from 'fs';
import { join, dirname, basename } from 'path';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showBanner, createSpinner, success, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { getAllManifests, deleteManifest } from '../core/manifest.js';
/**
* Uninstall command handler
* @param {Object} options - Command options
*/
export async function uninstallCommand(options) {
showBanner();
console.log(chalk.cyan.bold(' Uninstall Claude Code Workflow\n'));
// Get all manifests
const manifests = getAllManifests();
if (manifests.length === 0) {
warning('No installations found.');
info('Nothing to uninstall.');
return;
}
// Display installations
console.log(chalk.white.bold(' Found installations:\n'));
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count} | Dirs: ${m.directories_count}`));
console.log('');
});
divider();
// Select installation to uninstall
let selectedManifest;
if (manifests.length === 1) {
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Uninstall ${manifests[0].installation_mode} installation at ${manifests[0].installation_path}?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[0];
} else {
const choices = manifests.map((m, i) => ({
name: `${m.installation_mode} - ${m.installation_path}`,
value: i
}));
choices.push({ name: chalk.gray('Cancel'), value: -1 });
const { selection } = await inquirer.prompt([{
type: 'list',
name: 'selection',
message: 'Select installation to uninstall:',
choices
}]);
if (selection === -1) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[selection];
// Confirm selection
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to uninstall ${selectedManifest.installation_mode} installation?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
}
console.log('');
// Perform uninstallation
const spinner = createSpinner('Removing files...').start();
let removedFiles = 0;
let removedDirs = 0;
let failedFiles = [];
try {
// Remove files first (in reverse order to handle nested files)
const files = [...(selectedManifest.files || [])].reverse();
for (const fileEntry of files) {
const filePath = fileEntry.path;
spinner.text = `Removing: ${basename(filePath)}`;
try {
if (existsSync(filePath)) {
unlinkSync(filePath);
removedFiles++;
}
} catch (err) {
failedFiles.push({ path: filePath, error: err.message });
}
}
// Remove directories (in reverse order to remove nested dirs first)
const directories = [...(selectedManifest.directories || [])].reverse();
// Sort by path length (deepest first)
directories.sort((a, b) => b.path.length - a.path.length);
for (const dirEntry of directories) {
const dirPath = dirEntry.path;
spinner.text = `Removing directory: ${basename(dirPath)}`;
try {
if (existsSync(dirPath)) {
// Only remove if empty
const contents = readdirSync(dirPath);
if (contents.length === 0) {
rmdirSync(dirPath);
removedDirs++;
}
}
} catch (err) {
// Ignore directory removal errors (might not be empty)
}
}
// Try to clean up parent directories if empty
const installPath = selectedManifest.installation_path;
for (const dir of ['.claude', '.codex', '.gemini', '.qwen']) {
const dirPath = join(installPath, dir);
try {
if (existsSync(dirPath)) {
await removeEmptyDirs(dirPath);
}
} catch {
// Ignore
}
}
spinner.succeed('Uninstall complete!');
} catch (err) {
spinner.fail('Uninstall failed');
error(err.message);
return;
}
// Delete manifest
deleteManifest(selectedManifest.manifest_file);
// Show summary
console.log('');
if (failedFiles.length > 0) {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.yellow.bold('⚠ Partially Completed'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
chalk.white(`Failed: ${chalk.red(failedFiles.length)}`),
'',
chalk.gray('Some files could not be removed.'),
chalk.gray('They may be in use or require elevated permissions.'),
],
borderColor: 'yellow'
});
if (process.env.DEBUG) {
console.log('');
console.log(chalk.gray('Failed files:'));
failedFiles.forEach(f => {
console.log(chalk.red(` ${f.path}: ${f.error}`));
});
}
} else {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.green.bold('✓ Successfully Uninstalled'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
'',
chalk.gray('Manifest removed'),
],
borderColor: 'green'
});
}
console.log('');
}
/**
* Recursively remove empty directories
* @param {string} dirPath - Directory path
*/
async function removeEmptyDirs(dirPath) {
if (!existsSync(dirPath)) return;
const stat = statSync(dirPath);
if (!stat.isDirectory()) return;
let files = readdirSync(dirPath);
// Recursively check subdirectories
for (const file of files) {
const filePath = join(dirPath, file);
if (statSync(filePath).isDirectory()) {
await removeEmptyDirs(filePath);
}
}
// Re-check after processing subdirectories
files = readdirSync(dirPath);
if (files.length === 0) {
rmdirSync(dirPath);
}
}

132
ccw/src/commands/view.js Normal file
View File

@@ -0,0 +1,132 @@
import { scanSessions } from '../core/session-scanner.js';
import { aggregateData } from '../core/data-aggregator.js';
import { generateDashboard } from '../core/dashboard-generator.js';
import { launchBrowser, isHeadlessEnvironment } from '../utils/browser-launcher.js';
import { resolvePath, ensureDir, getWorkflowDir, validatePath, validateOutputPath } from '../utils/path-resolver.js';
import chalk from 'chalk';
import { writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
/**
* View command handler - generates and opens workflow dashboard
* @param {Object} options - Command options
*/
export async function viewCommand(options) {
// Validate project path
const pathValidation = validatePath(options.path, { mustExist: true });
if (!pathValidation.valid) {
console.error(chalk.red(`\n Error: ${pathValidation.error}\n`));
process.exit(1);
}
const workingDir = pathValidation.path;
const workflowDir = join(workingDir, '.workflow');
console.log(chalk.blue.bold('\n CCW Dashboard Generator\n'));
console.log(chalk.gray(` Project: ${workingDir}`));
console.log(chalk.gray(` Workflow: ${workflowDir}\n`));
// Check if .workflow directory exists
if (!existsSync(workflowDir)) {
console.log(chalk.yellow(' No .workflow directory found.'));
console.log(chalk.gray(' This project may not have any workflow sessions yet.\n'));
// Still generate an empty dashboard
const emptyData = {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
reviewData: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0
}
};
await generateAndOpen(emptyData, workflowDir, options);
return;
}
try {
// Step 1: Scan for sessions
console.log(chalk.cyan(' Scanning sessions...'));
const sessions = await scanSessions(workflowDir);
console.log(chalk.green(` Found ${sessions.active.length} active, ${sessions.archived.length} archived sessions`));
if (sessions.hasReviewData) {
console.log(chalk.magenta(' Review data detected - will include Reviews tab'));
}
// Step 2: Aggregate all data
console.log(chalk.cyan(' Aggregating data...'));
const dashboardData = await aggregateData(sessions, workflowDir);
// Log statistics
const stats = dashboardData.statistics;
console.log(chalk.gray(` Tasks: ${stats.completedTasks}/${stats.totalTasks} completed`));
if (stats.reviewFindings > 0) {
console.log(chalk.gray(` Review findings: ${stats.reviewFindings}`));
}
// Step 3 & 4: Generate and open
await generateAndOpen(dashboardData, workflowDir, options);
} catch (error) {
console.error(chalk.red(`\n Error: ${error.message}\n`));
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
/**
* Generate dashboard and optionally open in browser
* @param {Object} data - Dashboard data
* @param {string} workflowDir - Path to .workflow
* @param {Object} options - Command options
*/
async function generateAndOpen(data, workflowDir, options) {
// Step 3: Generate dashboard HTML
console.log(chalk.cyan(' Generating dashboard...'));
const html = await generateDashboard(data);
// Step 4: Validate and write dashboard file
let outputPath;
if (options.output) {
const outputValidation = validateOutputPath(options.output, workflowDir);
if (!outputValidation.valid) {
console.error(chalk.red(`\n Error: ${outputValidation.error}\n`));
process.exit(1);
}
outputPath = outputValidation.path;
} else {
outputPath = join(workflowDir, 'dashboard.html');
}
ensureDir(dirname(outputPath));
writeFileSync(outputPath, html, 'utf8');
console.log(chalk.green(` Dashboard saved: ${outputPath}`));
// Step 5: Open in browser (unless --no-browser or headless environment)
if (options.browser !== false) {
if (isHeadlessEnvironment()) {
console.log(chalk.yellow('\n Running in CI/headless environment - skipping browser launch'));
console.log(chalk.gray(` Open manually: file://${outputPath.replace(/\\/g, '/')}\n`));
} else {
console.log(chalk.cyan(' Opening in browser...'));
try {
await launchBrowser(outputPath);
console.log(chalk.green.bold('\n Dashboard opened in browser!\n'));
} catch (error) {
console.log(chalk.yellow(`\n Could not open browser: ${error.message}`));
console.log(chalk.gray(` Open manually: file://${outputPath.replace(/\\/g, '/')}\n`));
}
}
} else {
console.log(chalk.gray(`\n Open in browser: file://${outputPath.replace(/\\/g, '/')}\n`));
}
}