diff --git a/ccw/src/core/server.js b/ccw/src/core/server.js index a4c53010..1c84cd94 100644 --- a/ccw/src/core/server.js +++ b/ccw/src/core/server.js @@ -86,6 +86,7 @@ const MODULE_FILES = [ 'components/carousel.js', 'components/notifications.js', 'components/global-notifications.js', + 'components/version-check.js', 'components/mcp-manager.js', 'components/hook-manager.js', 'components/_exp_helpers.js', @@ -191,6 +192,15 @@ export async function startServer(options = {}) { return; } + // API: Version check (check for npm updates) + if (pathname === '/api/version-check') { + const versionData = await checkNpmVersion(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(versionData)); + return; + } + + // API: Shutdown server (for ccw stop command) if (pathname === '/api/shutdown' && req.method === 'POST') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -1946,3 +1956,108 @@ async function triggerUpdateClaudeMd(targetPath, tool, strategy) { }, 300000); }); } + + +// ======================================== +// Version Check Functions +// ======================================== + +// Package name on npm registry +const NPM_PACKAGE_NAME = 'claude-code-workflow'; + +// Cache for version check (avoid too frequent requests) +let versionCheckCache = null; +let versionCheckTime = 0; +const VERSION_CHECK_CACHE_TTL = 3600000; // 1 hour + +/** + * Get current package version from package.json + * @returns {string} + */ +function getCurrentVersion() { + try { + const packageJsonPath = join(import.meta.dirname, '../../../package.json'); + if (existsSync(packageJsonPath)) { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return pkg.version || '0.0.0'; + } + } catch (e) { + console.error('Error reading package.json:', e); + } + return '0.0.0'; +} + +/** + * Check npm registry for latest version + * @returns {Promise} + */ +async function checkNpmVersion() { + // Return cached result if still valid + const now = Date.now(); + if (versionCheckCache && (now - versionCheckTime) < VERSION_CHECK_CACHE_TTL) { + return versionCheckCache; + } + + const currentVersion = getCurrentVersion(); + + try { + // Fetch latest version from npm registry + const npmUrl = 'https://registry.npmjs.org/' + encodeURIComponent(NPM_PACKAGE_NAME) + '/latest'; + const response = await fetch(npmUrl, { + headers: { 'Accept': 'application/json' } + }); + + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + + const data = await response.json(); + const latestVersion = data.version; + + // Compare versions + const hasUpdate = compareVersions(latestVersion, currentVersion) > 0; + + const result = { + currentVersion, + latestVersion, + hasUpdate, + packageName: NPM_PACKAGE_NAME, + updateCommand: 'npm update -g ' + NPM_PACKAGE_NAME, + checkedAt: new Date().toISOString() + }; + + // Cache the result + versionCheckCache = result; + versionCheckTime = now; + + return result; + } catch (error) { + console.error('Version check failed:', error.message); + return { + currentVersion, + latestVersion: null, + hasUpdate: false, + error: error.message, + checkedAt: new Date().toISOString() + }; + } +} + +/** + * Compare two semver versions + * @param {string} v1 + * @param {string} v2 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ +function compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < 3; i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} diff --git a/ccw/src/templates/dashboard-css/01-base.css b/ccw/src/templates/dashboard-css/01-base.css index ed9ff16b..276feb99 100644 --- a/ccw/src/templates/dashboard-css/01-base.css +++ b/ccw/src/templates/dashboard-css/01-base.css @@ -159,3 +159,133 @@ body { display: block; } + +/* =================================== + Version Update Banner + =================================== */ + +.version-update-banner { + position: sticky; + top: 0; + z-index: 100; + background: linear-gradient(135deg, hsl(var(--primary) / 0.1), hsl(var(--accent) / 0.1)); + border-bottom: 1px solid hsl(var(--primary) / 0.3); + padding: 0.75rem 1rem; + transform: translateY(-100%); + opacity: 0; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.version-update-banner.show { + transform: translateY(0); + opacity: 1; +} + +.version-banner-content { + display: flex; + align-items: center; + gap: 0.75rem; + max-width: 1400px; + margin: 0 auto; + flex-wrap: wrap; +} + +.version-banner-icon { + font-size: 1.25rem; +} + +.version-banner-text { + flex: 1; + font-size: 0.875rem; + color: hsl(var(--foreground)); +} + +.version-banner-text code { + background: hsl(var(--muted)); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: var(--font-mono); + font-size: 0.8125rem; +} + +.version-banner-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--primary-foreground)); + background: hsl(var(--primary)); + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.version-banner-btn:hover { + background: hsl(var(--primary) / 0.9); + transform: translateY(-1px); +} + +.version-banner-btn:active { + transform: translateY(0); +} + +.version-banner-btn.secondary { + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); +} + +.version-banner-btn.secondary:hover { + background: hsl(var(--secondary) / 0.8); +} + +.version-banner-close { + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + color: hsl(var(--muted-foreground)); + background: transparent; + border: none; + border-radius: 0.25rem; + cursor: pointer; + transition: background 0.2s, color 0.2s; + margin-left: auto; +} + +.version-banner-close:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +/* Mobile responsiveness for banner */ +@media (max-width: 640px) { + .version-banner-content { + gap: 0.5rem; + } + + .version-banner-text { + width: 100%; + order: -1; + } + + .version-banner-btn { + flex: 1; + justify-content: center; + } + + .version-banner-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + } + + .version-update-banner { + position: relative; + padding-right: 2.5rem; + } +} diff --git a/ccw/src/templates/dashboard-js/components/version-check.js b/ccw/src/templates/dashboard-js/components/version-check.js new file mode 100644 index 00000000..fb4f8fb1 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/version-check.js @@ -0,0 +1,167 @@ +// ========================================== +// VERSION CHECK COMPONENT +// ========================================== +// Checks for npm package updates and displays upgrade notification + +// State +let versionCheckData = null; +let versionBannerDismissed = false; + +/** + * Initialize version check on page load + */ +async function initVersionCheck() { + // Check version after a short delay to not block initial render + setTimeout(async () => { + await checkForUpdates(); + }, 2000); +} + +/** + * Check for package updates + */ +async function checkForUpdates() { + try { + const res = await fetch('/api/version-check'); + if (!res.ok) return; + + versionCheckData = await res.json(); + + if (versionCheckData.hasUpdate && !versionBannerDismissed) { + showUpdateBanner(versionCheckData); + addGlobalNotification( + 'info', + 'Update Available', + 'Version ' + versionCheckData.latestVersion + ' is now available. Current: ' + versionCheckData.currentVersion, + 'system' + ); + } + } catch (err) { + console.log('Version check skipped:', err.message); + } +} + +/** + * Show update banner at top of page + */ +function showUpdateBanner(data) { + // Remove existing banner if any + const existing = document.getElementById('versionUpdateBanner'); + if (existing) existing.remove(); + + const banner = document.createElement('div'); + banner.id = 'versionUpdateBanner'; + banner.className = 'version-update-banner'; + banner.innerHTML = '\ +
\ + 🚀\ + \ + Update Available! \ + Version ' + escapeHtml(data.latestVersion) + ' is available \ + (you have ' + escapeHtml(data.currentVersion) + ')\ + \ + \ + \ + \ +
\ + '; + + // Insert at top of main content + const mainContent = document.querySelector('.main-content'); + if (mainContent) { + mainContent.insertBefore(banner, mainContent.firstChild); + } else { + document.body.insertBefore(banner, document.body.firstChild); + } + + // Animate in + requestAnimationFrame(() => banner.classList.add('show')); +} + +/** + * Dismiss update banner + */ +function dismissUpdateBanner() { + versionBannerDismissed = true; + const banner = document.getElementById('versionUpdateBanner'); + if (banner) { + banner.classList.remove('show'); + setTimeout(() => banner.remove(), 300); + } +} + +/** + * Copy update command to clipboard + */ +async function copyUpdateCommand() { + if (!versionCheckData) return; + + try { + await navigator.clipboard.writeText(versionCheckData.updateCommand); + addGlobalNotification('success', 'Command copied to clipboard', versionCheckData.updateCommand, 'version-check'); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = versionCheckData.updateCommand; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + addGlobalNotification('success', 'Command copied to clipboard', null, 'version-check'); + } +} + +/** + * Show update details modal + */ +function showUpdateModal() { + if (!versionCheckData) return; + + const content = '\ +# Update Available\n\ +\n\ +A new version of Claude Code Workflow is available!\n\ +\n\ +| Property | Value |\n\ +|----------|-------|\n\ +| Current Version | `' + versionCheckData.currentVersion + '` |\n\ +| Latest Version | `' + versionCheckData.latestVersion + '` |\n\ +| Package | `' + versionCheckData.packageName + '` |\n\ +\n\ +## Update Command\n\ +\n\ +```bash\n\ +' + versionCheckData.updateCommand + '\n\ +```\n\ +\n\ +## Alternative Methods\n\ +\n\ +### Using ccw upgrade command\n\ +```bash\n\ +ccw upgrade\n\ +```\n\ +\n\ +### Fresh install\n\ +```bash\n\ +npm install -g ' + versionCheckData.packageName + '@latest\n\ +```\n\ +\n\ +---\n\ +*Checked at: ' + new Date(versionCheckData.checkedAt).toLocaleString() + '*\n\ +'; + + showMarkdownModal(content, 'Update Available - v' + versionCheckData.latestVersion); +} + +/** + * Get current version info (for display in UI) + */ +function getVersionInfo() { + return versionCheckData; +} diff --git a/ccw/src/templates/dashboard-js/main.js b/ccw/src/templates/dashboard-js/main.js index a0ae670d..59a387a9 100644 --- a/ccw/src/templates/dashboard-js/main.js +++ b/ccw/src/templates/dashboard-js/main.js @@ -16,6 +16,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); } try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); } try { initGlobalNotifications(); } catch (e) { console.error('Global notifications init failed:', e); } + try { initVersionCheck(); } catch (e) { console.error('Version check init failed:', e); } // Initialize real-time features (WebSocket + auto-refresh) try { initWebSocket(); } catch (e) { console.log('WebSocket not available:', e.message); } diff --git a/ccw/src/templates/dashboard-js/views/hook-manager.js b/ccw/src/templates/dashboard-js/views/hook-manager.js index e402cecf..777bd5ca 100644 --- a/ccw/src/templates/dashboard-js/views/hook-manager.js +++ b/ccw/src/templates/dashboard-js/views/hook-manager.js @@ -330,9 +330,10 @@ function attachHookEventListeners() { // Edit buttons document.querySelectorAll('.hook-card button[data-action="edit"]').forEach(btn => { btn.addEventListener('click', (e) => { - const scope = e.target.dataset.scope; - const event = e.target.dataset.event; - const index = parseInt(e.target.dataset.index); + const button = e.currentTarget; + const scope = button.dataset.scope; + const event = button.dataset.event; + const index = parseInt(button.dataset.index); const hooks = scope === 'global' ? hookConfig.global.hooks : hookConfig.project.hooks; const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]]; @@ -354,9 +355,10 @@ function attachHookEventListeners() { // Delete buttons document.querySelectorAll('.hook-card button[data-action="delete"]').forEach(btn => { btn.addEventListener('click', async (e) => { - const scope = e.target.dataset.scope; - const event = e.target.dataset.event; - const index = parseInt(e.target.dataset.index); + const button = e.currentTarget; + const scope = button.dataset.scope; + const event = button.dataset.event; + const index = parseInt(button.dataset.index); if (confirm(`Remove this ${event} hook?`)) { await removeHook(scope, event, index); @@ -367,7 +369,7 @@ function attachHookEventListeners() { // Install project buttons document.querySelectorAll('button[data-action="install-project"]').forEach(btn => { btn.addEventListener('click', async (e) => { - const templateId = e.target.dataset.template; + const templateId = e.currentTarget.dataset.template; await installHookTemplate(templateId, 'project'); }); }); @@ -375,7 +377,7 @@ function attachHookEventListeners() { // Install global buttons document.querySelectorAll('button[data-action="install-global"]').forEach(btn => { btn.addEventListener('click', async (e) => { - const templateId = e.target.dataset.template; + const templateId = e.currentTarget.dataset.template; await installHookTemplate(templateId, 'global'); }); }); @@ -383,7 +385,7 @@ function attachHookEventListeners() { // Uninstall buttons document.querySelectorAll('button[data-action="uninstall"]').forEach(btn => { btn.addEventListener('click', async (e) => { - const templateId = e.target.dataset.template; + const templateId = e.currentTarget.dataset.template; await uninstallHookTemplate(templateId); }); }); diff --git a/package.json b/package.json index 469aa8a8..d487675f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-workflow", - "version": "6.1.1", + "version": "6.1.2", "description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution", "type": "module", "main": "ccw/src/index.js",