mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
167
ccw/src/templates/dashboard-js/components/version-check.js
Normal file
167
ccw/src/templates/dashboard-js/components/version-check.js
Normal 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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user