feat(dashboard): add npm version update notification

- Add /api/version-check endpoint to check npm registry for updates
- Create version-check.js component with update banner UI
- Add CSS styles for version update banner
- Fix hook manager button event handling (use e.currentTarget)
- Bump version to 6.1.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-09 14:36:34 +08:00
parent eb1093128e
commit 1267c8d0f4
6 changed files with 425 additions and 10 deletions

View File

@@ -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<Object>}
*/
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;
}

View File

@@ -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;
}
}

View File

@@ -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 = '\
<div class="version-banner-content">\
<span class="version-banner-icon">🚀</span>\
<span class="version-banner-text">\
<strong>Update Available!</strong> \
Version <code>' + escapeHtml(data.latestVersion) + '</code> is available \
(you have <code>' + escapeHtml(data.currentVersion) + '</code>)\
</span>\
<button class="version-banner-btn" onclick="copyUpdateCommand()">\
<span>📋</span> Copy Command\
</button>\
<button class="version-banner-btn secondary" onclick="showUpdateModal()">\
<span></span> Details\
</button>\
<button class="version-banner-close" onclick="dismissUpdateBanner()" title="Dismiss">\
×\
</button>\
</div>\
';
// 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;
}

View File

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

View File

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

View File

@@ -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",