mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: add workflow management commands and utilities
- Implemented workflow installation, listing, and syncing commands in `workflow.ts`. - Created utility functions for project root detection and package version retrieval in `project-root.ts`. - Added update checker functionality to notify users of new package versions in `update-checker.ts`. - Developed unit tests for project root utilities and update checker to ensure functionality and version comparison accuracy.
This commit is contained in:
@@ -13,6 +13,7 @@ import { memoryCommand } from './commands/memory.js';
|
||||
import { coreMemoryCommand } from './commands/core-memory.js';
|
||||
import { hookCommand } from './commands/hook.js';
|
||||
import { issueCommand } from './commands/issue.js';
|
||||
import { workflowCommand } from './commands/workflow.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
@@ -300,5 +301,13 @@ export function run(argv: string[]): void {
|
||||
.option('--queue <queue-id>', 'Target queue ID for multi-queue operations')
|
||||
.action((subcommand, args, options) => issueCommand(subcommand, args, options));
|
||||
|
||||
// Workflow command - Workflow installation and management
|
||||
program
|
||||
.command('workflow [subcommand] [args...]')
|
||||
.description('Workflow installation and management (install, list, sync)')
|
||||
.option('-f, --force', 'Force installation without prompts')
|
||||
.option('--source <source>', 'Install specific source only')
|
||||
.action((subcommand, args, options) => workflowCommand(subcommand, args, options));
|
||||
|
||||
program.parse(argv);
|
||||
}
|
||||
|
||||
@@ -98,8 +98,9 @@ function broadcastStreamEvent(eventType: string, payload: Record<string, unknown
|
||||
req.on('socket', (socket) => {
|
||||
socket.unref();
|
||||
});
|
||||
req.on('error', () => {
|
||||
// Silently ignore errors for streaming events
|
||||
req.on('error', (err) => {
|
||||
// Log errors for debugging - helps diagnose hook communication issues
|
||||
console.error(`[Hook] Failed to send ${eventType}:`, (err as Error).message);
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { serveCommand } from './serve.js';
|
||||
import { launchBrowser } from '../utils/browser-launcher.js';
|
||||
import { validatePath } from '../utils/path-resolver.js';
|
||||
import { checkForUpdates } from '../utils/update-checker.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface ViewOptions {
|
||||
@@ -68,6 +69,9 @@ async function switchWorkspace(port: number, path: string): Promise<SwitchWorksp
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function viewCommand(options: ViewOptions): Promise<void> {
|
||||
// Check for updates (fire-and-forget, non-blocking)
|
||||
checkForUpdates().catch(() => { /* ignore errors */ });
|
||||
|
||||
const port = options.port || 3456;
|
||||
const host = options.host || '127.0.0.1';
|
||||
const browserHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
||||
|
||||
348
ccw/src/commands/workflow.ts
Normal file
348
ccw/src/commands/workflow.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join, basename, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { showHeader, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
|
||||
import { getPackageRoot as findPackageRoot, getPackageVersion } from '../utils/project-root.js';
|
||||
|
||||
// Workflow source directories (relative to package root)
|
||||
const WORKFLOW_SOURCES = [
|
||||
{ name: '.claude/workflows', description: 'Claude workflows' },
|
||||
{ name: '.claude/scripts', description: 'Claude scripts' },
|
||||
{ name: '.claude/templates', description: 'Claude templates' },
|
||||
{ name: '.codex/prompts', description: 'Codex prompts' },
|
||||
{ name: '.gemini', description: 'Gemini configuration' },
|
||||
{ name: '.qwen', description: 'Qwen configuration' }
|
||||
];
|
||||
|
||||
interface WorkflowOptions {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface CopyStats {
|
||||
files: number;
|
||||
directories: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package root directory using robust path resolution
|
||||
*/
|
||||
function getPackageRoot(): string {
|
||||
return findPackageRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow installation target directory
|
||||
*/
|
||||
function getWorkflowTargetDir(): string {
|
||||
return homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version
|
||||
*/
|
||||
function getVersion(): string {
|
||||
return getPackageVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error with file path context
|
||||
*/
|
||||
class FileOperationError extends Error {
|
||||
constructor(message: string, public filePath: string, public operation: string) {
|
||||
super(`${operation} failed for ${filePath}: ${message}`);
|
||||
this.name = 'FileOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy directory recursively with stats tracking and detailed error handling
|
||||
*/
|
||||
async function copyDirectory(
|
||||
src: string,
|
||||
dest: string,
|
||||
stats: CopyStats = { files: 0, directories: 0, updated: 0, skipped: 0 }
|
||||
): Promise<CopyStats> {
|
||||
if (!existsSync(src)) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Create destination directory with error context
|
||||
if (!existsSync(dest)) {
|
||||
try {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
stats.directories++;
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
throw new FileOperationError(e.message, dest, 'Create directory');
|
||||
}
|
||||
}
|
||||
|
||||
const entries = readdirSync(src);
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip settings files
|
||||
if (entry === 'settings.json' || entry === 'settings.local.json') {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcPath = join(src, entry);
|
||||
const destPath = join(dest, entry);
|
||||
|
||||
try {
|
||||
const stat = statSync(srcPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await copyDirectory(srcPath, destPath, stats);
|
||||
} else {
|
||||
// Check if file needs update (use binary comparison for non-text files)
|
||||
if (existsSync(destPath)) {
|
||||
try {
|
||||
const srcContent = readFileSync(srcPath);
|
||||
const destContent = readFileSync(destPath);
|
||||
if (srcContent.equals(destContent)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
stats.updated++;
|
||||
} catch {
|
||||
// If comparison fails, proceed with copy
|
||||
stats.updated++;
|
||||
}
|
||||
}
|
||||
copyFileSync(srcPath, destPath);
|
||||
stats.files++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof FileOperationError) {
|
||||
throw err; // Re-throw our custom errors
|
||||
}
|
||||
const e = err as Error;
|
||||
throw new FileOperationError(e.message, srcPath, 'Copy file');
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed workflows
|
||||
*/
|
||||
async function listWorkflows(): Promise<void> {
|
||||
const targetDir = getWorkflowTargetDir();
|
||||
|
||||
console.log(chalk.blue.bold('\n Installed Workflows\n'));
|
||||
|
||||
let hasWorkflows = false;
|
||||
|
||||
for (const source of WORKFLOW_SOURCES) {
|
||||
const targetPath = join(targetDir, source.name);
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
hasWorkflows = true;
|
||||
const files = readdirSync(targetPath, { recursive: true });
|
||||
const fileCount = files.filter(f => {
|
||||
const fullPath = join(targetPath, f.toString());
|
||||
return existsSync(fullPath) && statSync(fullPath).isFile();
|
||||
}).length;
|
||||
|
||||
console.log(chalk.cyan(` ${source.name}`));
|
||||
console.log(chalk.gray(` Path: ${targetPath}`));
|
||||
console.log(chalk.gray(` Files: ${fileCount}`));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasWorkflows) {
|
||||
info('No workflows installed. Run: ccw workflow install');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install workflows to user home directory
|
||||
*/
|
||||
async function installWorkflows(options: WorkflowOptions): Promise<void> {
|
||||
const version = getVersion();
|
||||
showHeader(version);
|
||||
|
||||
const sourceDir = getPackageRoot();
|
||||
const targetDir = getWorkflowTargetDir();
|
||||
|
||||
// Filter sources if specific source requested
|
||||
let sources = WORKFLOW_SOURCES;
|
||||
if (options.source) {
|
||||
sources = WORKFLOW_SOURCES.filter(s => s.name.includes(options.source!));
|
||||
if (sources.length === 0) {
|
||||
error(`Unknown source: ${options.source}`);
|
||||
info(`Available sources: ${WORKFLOW_SOURCES.map(s => s.name).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate source directories exist
|
||||
const availableSources = sources.filter(s => existsSync(join(sourceDir, s.name)));
|
||||
|
||||
if (availableSources.length === 0) {
|
||||
error('No workflow sources found to install.');
|
||||
error(`Expected directories in: ${sourceDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
info(`Found ${availableSources.length} workflow sources to install:`);
|
||||
availableSources.forEach(s => {
|
||||
console.log(chalk.gray(` - ${s.name} (${s.description})`));
|
||||
});
|
||||
|
||||
divider();
|
||||
|
||||
// Confirm installation
|
||||
if (!options.force) {
|
||||
const { proceed } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'proceed',
|
||||
message: `Install workflows to ${targetDir}?`,
|
||||
default: true
|
||||
}]);
|
||||
|
||||
if (!proceed) {
|
||||
info('Installation cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform installation
|
||||
console.log('');
|
||||
const spinner = createSpinner('Installing workflows...').start();
|
||||
|
||||
const totalStats: CopyStats = { files: 0, directories: 0, updated: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
for (const source of availableSources) {
|
||||
const srcPath = join(sourceDir, source.name);
|
||||
const destPath = join(targetDir, source.name);
|
||||
|
||||
spinner.text = `Installing ${source.name}...`;
|
||||
const stats = await copyDirectory(srcPath, destPath);
|
||||
|
||||
totalStats.files += stats.files;
|
||||
totalStats.directories += stats.directories;
|
||||
totalStats.updated += stats.updated;
|
||||
totalStats.skipped += stats.skipped;
|
||||
}
|
||||
|
||||
// Write version marker
|
||||
const versionPath = join(targetDir, '.claude', 'workflow-version.json');
|
||||
if (existsSync(dirname(versionPath))) {
|
||||
const versionData = {
|
||||
version,
|
||||
installedAt: new Date().toISOString(),
|
||||
installer: 'ccw workflow'
|
||||
};
|
||||
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
spinner.succeed('Workflow installation complete!');
|
||||
|
||||
} catch (err) {
|
||||
spinner.fail('Installation failed');
|
||||
const errMsg = err as Error;
|
||||
error(errMsg.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show summary
|
||||
console.log('');
|
||||
const summaryLines = [
|
||||
chalk.green.bold('\u2713 Workflow Installation Successful'),
|
||||
'',
|
||||
chalk.white(`Target: ${chalk.cyan(targetDir)}`),
|
||||
chalk.white(`Version: ${chalk.cyan(version)}`),
|
||||
'',
|
||||
chalk.gray(`New files: ${totalStats.files}`),
|
||||
chalk.gray(`Updated: ${totalStats.updated}`),
|
||||
chalk.gray(`Skipped: ${totalStats.skipped}`),
|
||||
chalk.gray(`Directories: ${totalStats.directories}`)
|
||||
];
|
||||
|
||||
summaryBox({
|
||||
title: ' Workflow Summary ',
|
||||
lines: summaryLines,
|
||||
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. Workflows are now available globally'));
|
||||
console.log(chalk.gray(' 3. Run: ccw workflow list - to see installed workflows'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync workflows (update existing installation)
|
||||
*/
|
||||
async function syncWorkflows(options: WorkflowOptions): Promise<void> {
|
||||
info('Syncing workflows (same as install with updates)...');
|
||||
await installWorkflows({ ...options, force: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show workflow help
|
||||
*/
|
||||
function showWorkflowHelp(): void {
|
||||
console.log(chalk.blue.bold('\n CCW Workflow Manager\n'));
|
||||
console.log(chalk.white(' Usage:'));
|
||||
console.log(chalk.gray(' ccw workflow <command> [options]'));
|
||||
console.log('');
|
||||
console.log(chalk.white(' Commands:'));
|
||||
console.log(chalk.cyan(' install') + chalk.gray(' Install workflows to global directory (~/)'));
|
||||
console.log(chalk.cyan(' list') + chalk.gray(' List installed workflows'));
|
||||
console.log(chalk.cyan(' sync') + chalk.gray(' Sync/update workflows from package'));
|
||||
console.log('');
|
||||
console.log(chalk.white(' Options:'));
|
||||
console.log(chalk.gray(' -f, --force Force installation without prompts'));
|
||||
console.log(chalk.gray(' --source Install specific source only'));
|
||||
console.log('');
|
||||
console.log(chalk.white(' Examples:'));
|
||||
console.log(chalk.gray(' ccw workflow install # Install all workflows'));
|
||||
console.log(chalk.gray(' ccw workflow install -f # Force install'));
|
||||
console.log(chalk.gray(' ccw workflow install --source .claude/workflows'));
|
||||
console.log(chalk.gray(' ccw workflow list # List installed'));
|
||||
console.log(chalk.gray(' ccw workflow sync # Update workflows'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main workflow command handler
|
||||
*/
|
||||
export async function workflowCommand(
|
||||
subcommand?: string,
|
||||
args?: string[],
|
||||
options: WorkflowOptions = {}
|
||||
): Promise<void> {
|
||||
switch (subcommand) {
|
||||
case 'install':
|
||||
await installWorkflows(options);
|
||||
break;
|
||||
case 'list':
|
||||
case 'ls':
|
||||
await listWorkflows();
|
||||
break;
|
||||
case 'sync':
|
||||
case 'update':
|
||||
await syncWorkflows(options);
|
||||
break;
|
||||
case 'help':
|
||||
default:
|
||||
showWorkflowHelp();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,20 @@ export function updateActiveExecution(event: {
|
||||
activeExec.output += output;
|
||||
}
|
||||
} else if (type === 'completed') {
|
||||
// Remove from active executions
|
||||
activeExecutions.delete(executionId);
|
||||
// Mark as completed instead of immediately deleting
|
||||
// Keep execution visible for 5 minutes to allow page refreshes to see it
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
activeExec.status = success ? 'completed' : 'error';
|
||||
|
||||
// Auto-cleanup after 5 minutes
|
||||
setTimeout(() => {
|
||||
activeExecutions.delete(executionId);
|
||||
console.log(`[ActiveExec] Auto-cleaned completed execution: ${executionId}`);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
console.log(`[ActiveExec] Marked as ${activeExec.status}, will auto-clean in 5 minutes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,100 @@
|
||||
// State
|
||||
let versionCheckData = null;
|
||||
let versionBannerDismissed = false;
|
||||
let autoUpdateEnabled = true; // Default to enabled
|
||||
|
||||
/**
|
||||
* Initialize version check on page load
|
||||
*/
|
||||
async function initVersionCheck() {
|
||||
// Load auto-update setting from localStorage
|
||||
const stored = localStorage.getItem('ccw.autoUpdate');
|
||||
autoUpdateEnabled = stored !== null ? stored === 'true' : true;
|
||||
|
||||
// Update toggle checkbox state
|
||||
updateAutoUpdateToggleUI();
|
||||
|
||||
// Check version after a short delay to not block initial render
|
||||
setTimeout(async () => {
|
||||
await checkForUpdates();
|
||||
if (autoUpdateEnabled) {
|
||||
await checkForUpdates();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle auto-update setting (called from checkbox change event)
|
||||
*/
|
||||
function toggleAutoUpdate() {
|
||||
const checkbox = document.getElementById('autoUpdateToggle');
|
||||
if (!checkbox) return;
|
||||
|
||||
autoUpdateEnabled = checkbox.checked;
|
||||
localStorage.setItem('ccw.autoUpdate', autoUpdateEnabled.toString());
|
||||
|
||||
// Show notification
|
||||
if (autoUpdateEnabled) {
|
||||
addGlobalNotification('success', 'Auto-update enabled', 'Version check will run automatically', 'version-check');
|
||||
// Run check immediately if just enabled
|
||||
checkForUpdates();
|
||||
} else {
|
||||
addGlobalNotification('info', 'Auto-update disabled', 'Version check is turned off', 'version-check');
|
||||
// Dismiss banner if visible
|
||||
dismissUpdateBanner();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates immediately (called from "Check Now" button)
|
||||
*/
|
||||
async function checkForUpdatesNow() {
|
||||
const btn = document.getElementById('checkUpdateNow');
|
||||
if (btn) {
|
||||
// Add loading animation
|
||||
btn.classList.add('animate-spin');
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
// Force check regardless of toggle state
|
||||
const originalState = autoUpdateEnabled;
|
||||
autoUpdateEnabled = true;
|
||||
|
||||
try {
|
||||
await checkForUpdates();
|
||||
addGlobalNotification('success', 'Update check complete', 'Checked for latest version', 'version-check');
|
||||
} catch (err) {
|
||||
addGlobalNotification('error', 'Update check failed', err.message, 'version-check');
|
||||
} finally {
|
||||
// Restore original state
|
||||
autoUpdateEnabled = originalState;
|
||||
|
||||
if (btn) {
|
||||
btn.classList.remove('animate-spin');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auto-update toggle checkbox state
|
||||
*/
|
||||
function updateAutoUpdateToggleUI() {
|
||||
const checkbox = document.getElementById('autoUpdateToggle');
|
||||
if (!checkbox) return;
|
||||
|
||||
checkbox.checked = autoUpdateEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for package updates
|
||||
*/
|
||||
async function checkForUpdates() {
|
||||
// Respect the toggle setting
|
||||
if (!autoUpdateEnabled) {
|
||||
console.log('Version check skipped: auto-update is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/version-check');
|
||||
if (!res.ok) return;
|
||||
@@ -165,3 +244,10 @@ npm install -g ' + versionCheckData.packageName + '@latest\n\
|
||||
function getVersionInfo() {
|
||||
return versionCheckData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-update is enabled
|
||||
*/
|
||||
function isAutoUpdateEnabled() {
|
||||
return autoUpdateEnabled;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ const i18n = {
|
||||
'header.recentProjects': 'Recent Projects',
|
||||
'header.browse': 'Browse...',
|
||||
'header.refreshWorkspace': 'Refresh workspace',
|
||||
'header.checkUpdateNow': 'Check for updates now',
|
||||
'header.autoUpdate': 'Auto-update check',
|
||||
'header.toggleTheme': 'Toggle theme',
|
||||
'header.language': 'Language',
|
||||
'header.cliStream': 'CLI Stream Viewer',
|
||||
@@ -2391,6 +2393,8 @@ const i18n = {
|
||||
'header.recentProjects': '最近项目',
|
||||
'header.browse': '浏览...',
|
||||
'header.refreshWorkspace': '刷新工作区',
|
||||
'header.checkUpdateNow': '立即检查更新',
|
||||
'header.autoUpdate': '自动更新检查',
|
||||
'header.toggleTheme': '切换主题',
|
||||
'header.language': '语言',
|
||||
'header.cliStream': 'CLI 流式输出',
|
||||
|
||||
@@ -234,6 +234,52 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Auto-Update Toggle Switch */
|
||||
.auto-update-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auto-update-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.auto-update-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: hsl(var(--muted));
|
||||
transition: 0.3s;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.auto-update-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.auto-update-switch input:checked + .auto-update-slider {
|
||||
background-color: hsl(var(--success));
|
||||
}
|
||||
.auto-update-switch input:checked + .auto-update-slider:before {
|
||||
transform: translateX(14px);
|
||||
background-color: white;
|
||||
}
|
||||
.auto-update-switch:hover .auto-update-slider {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Injected from dashboard-css/*.css modules */
|
||||
{{CSS_CONTENT}}
|
||||
</style>
|
||||
@@ -296,6 +342,22 @@
|
||||
<path d="M16 21h5v-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Auto-Update Controls -->
|
||||
<div class="flex items-center gap-2 border-l border-border pl-2">
|
||||
<!-- Check Now Button -->
|
||||
<button class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded" id="checkUpdateNow" data-i18n-title="header.checkUpdateNow" title="Check for updates now" onclick="checkForUpdatesNow()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Auto-Update Toggle Switch -->
|
||||
<label class="auto-update-switch" data-i18n-title="header.autoUpdate" title="Auto-update check">
|
||||
<input type="checkbox" id="autoUpdateToggle" onchange="toggleAutoUpdate()" checked>
|
||||
<span class="auto-update-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
|
||||
73
ccw/src/utils/project-root.ts
Normal file
73
ccw/src/utils/project-root.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
interface PackageInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find project root by searching upward for package.json
|
||||
* More robust than hardcoded relative paths
|
||||
*/
|
||||
export function findProjectRoot(startDir: string = __dirname): string | null {
|
||||
let currentDir = startDir;
|
||||
let previousDir = '';
|
||||
|
||||
// Traverse up until we find package.json or reach filesystem root
|
||||
while (currentDir !== previousDir) {
|
||||
const pkgPath = join(currentDir, 'package.json');
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
// Verify this is our package (not a nested node_modules package)
|
||||
if (pkg.name === 'claude-code-workflow' || pkg.bin?.ccw) {
|
||||
return currentDir;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON, continue searching
|
||||
}
|
||||
}
|
||||
previousDir = currentDir;
|
||||
currentDir = dirname(currentDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load package.json from project root
|
||||
* Returns null if not found or invalid
|
||||
*/
|
||||
export function loadPackageInfo(): PackageInfo | null {
|
||||
const projectRoot = findProjectRoot();
|
||||
if (!projectRoot) return null;
|
||||
|
||||
const pkgPath = join(projectRoot, 'package.json');
|
||||
try {
|
||||
const content = readFileSync(pkgPath, 'utf8');
|
||||
return JSON.parse(content) as PackageInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version from project root
|
||||
*/
|
||||
export function getPackageVersion(): string {
|
||||
const pkg = loadPackageInfo();
|
||||
return pkg?.version || '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package root directory
|
||||
*/
|
||||
export function getPackageRoot(): string {
|
||||
return findProjectRoot() || join(__dirname, '..', '..', '..');
|
||||
}
|
||||
178
ccw/src/utils/update-checker.ts
Normal file
178
ccw/src/utils/update-checker.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import chalk from 'chalk';
|
||||
import { loadPackageInfo } from './project-root.js';
|
||||
|
||||
interface CacheData {
|
||||
lastCheck: number;
|
||||
latestVersion: string | null;
|
||||
}
|
||||
|
||||
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const CACHE_DIR = join(homedir(), '.config', 'ccw');
|
||||
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
|
||||
/**
|
||||
* Load cached update check data
|
||||
*/
|
||||
function loadCache(): CacheData | null {
|
||||
try {
|
||||
if (existsSync(CACHE_FILE)) {
|
||||
return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save update check data to cache
|
||||
*/
|
||||
function saveCache(data: CacheData): void {
|
||||
try {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse semver version into components
|
||||
* Handles: 1.2.3, v1.2.3, 1.2.3-alpha.1, 1.2.3-beta, 1.2.3-rc.2
|
||||
*/
|
||||
function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease: string[] } {
|
||||
const cleaned = version.replace(/^v/, '');
|
||||
const [mainPart, prereleasePart] = cleaned.split('-');
|
||||
const parts = mainPart.split('.');
|
||||
const major = parseInt(parts[0], 10) || 0;
|
||||
const minor = parseInt(parts[1], 10) || 0;
|
||||
const patch = parseInt(parts[2], 10) || 0;
|
||||
const prerelease = prereleasePart ? prereleasePart.split('.') : [];
|
||||
|
||||
return { major, minor, patch, prerelease };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver versions
|
||||
* Returns: 1 if a > b, -1 if a < b, 0 if equal
|
||||
* Properly handles prerelease versions (1.0.0-alpha < 1.0.0)
|
||||
*/
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const vA = parseVersion(a);
|
||||
const vB = parseVersion(b);
|
||||
|
||||
// Compare major.minor.patch
|
||||
if (vA.major !== vB.major) return vA.major > vB.major ? 1 : -1;
|
||||
if (vA.minor !== vB.minor) return vA.minor > vB.minor ? 1 : -1;
|
||||
if (vA.patch !== vB.patch) return vA.patch > vB.patch ? 1 : -1;
|
||||
|
||||
// Handle prerelease: no prerelease > has prerelease
|
||||
// e.g., 1.0.0 > 1.0.0-alpha
|
||||
if (vA.prerelease.length === 0 && vB.prerelease.length > 0) return 1;
|
||||
if (vA.prerelease.length > 0 && vB.prerelease.length === 0) return -1;
|
||||
|
||||
// Compare prerelease identifiers
|
||||
const maxLen = Math.max(vA.prerelease.length, vB.prerelease.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const partA = vA.prerelease[i];
|
||||
const partB = vB.prerelease[i];
|
||||
|
||||
// Missing part is less (1.0.0-alpha < 1.0.0-alpha.1)
|
||||
if (partA === undefined) return -1;
|
||||
if (partB === undefined) return 1;
|
||||
|
||||
// Numeric comparison if both are numbers
|
||||
const numA = parseInt(partA, 10);
|
||||
const numB = parseInt(partB, 10);
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
if (numA !== numB) return numA > numB ? 1 : -1;
|
||||
} else {
|
||||
// String comparison
|
||||
if (partA !== partB) return partA > partB ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest version from npm registry
|
||||
*/
|
||||
async function fetchLatestVersion(packageName: string): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json() as { version?: string };
|
||||
return data.version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for npm package updates and notify user
|
||||
* Non-blocking, with caching to avoid frequent requests
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
try {
|
||||
const pkg = loadPackageInfo();
|
||||
if (!pkg) return;
|
||||
|
||||
// Check cache first
|
||||
const cache = loadCache();
|
||||
const now = Date.now();
|
||||
|
||||
let latestVersion: string | null = null;
|
||||
|
||||
// Use cached version if within check interval
|
||||
if (cache && (now - cache.lastCheck) < CHECK_INTERVAL) {
|
||||
latestVersion = cache.latestVersion;
|
||||
} else {
|
||||
// Fetch from npm registry
|
||||
latestVersion = await fetchLatestVersion(pkg.name);
|
||||
|
||||
// Update cache
|
||||
saveCache({
|
||||
lastCheck: now,
|
||||
latestVersion
|
||||
});
|
||||
}
|
||||
|
||||
// Compare and notify (only for stable releases, ignore prerelease)
|
||||
if (latestVersion && compareVersions(latestVersion, pkg.version) > 0) {
|
||||
console.log('');
|
||||
console.log(chalk.yellow.bold(' \u26a0 New version available!'));
|
||||
console.log(chalk.gray(` Current: ${pkg.version} \u2192 Latest: ${chalk.green(latestVersion)}`));
|
||||
console.log(chalk.cyan(` Run: npm update -g ${pkg.name}`));
|
||||
console.log('');
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore update check errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and notify (async, non-blocking)
|
||||
* Call this at the start of commands that should show update notifications
|
||||
*/
|
||||
export function notifyUpdates(): void {
|
||||
// Run in background, don't block main execution
|
||||
checkForUpdates().catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user