+
diff --git a/chat/server.js b/chat/server.js
index 87c92c8..96b7044 100644
--- a/chat/server.js
+++ b/chat/server.js
@@ -17,6 +17,7 @@ const PDFDocument = require('pdfkit');
const security = require('./security');
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
const blogSystem = require('./blog-system');
+const versionManager = require('./src/utils/versionManager');
let sharp = null;
try {
@@ -7476,6 +7477,9 @@ function serializeSession(session) {
planSummary: session.planSummary,
planUserRequest: session.planUserRequest,
planApproved: !!session.planApproved,
+ pluginVersion: session.pluginVersion,
+ pluginVersionHistory: session.pluginVersionHistory || [],
+ lastVersionBumpType: session.lastVersionBumpType,
};
}
@@ -7654,7 +7658,11 @@ async function createSession(payload = {}, userId, appId) {
createdAt: now,
updatedAt: now,
messages: [],
- pending: 0
+ pending: 0,
+ // Version tracking for WordPress plugins
+ pluginVersion: null, // Will be detected from plugin file on first export
+ pluginVersionHistory: [], // Track version changes over time
+ lastVersionBumpType: null // 'major', 'minor', 'patch', or 'keep'
};
// WordPress identifies plugins by their folder + main file (plugin basename).
@@ -16829,6 +16837,55 @@ async function handleUploadApp(req, res, userId) {
const files = await extractZipToWorkspace(zipBuffer, session.workspaceDir);
session.planSummary = session.planSummary || 'Imported from ZIP upload';
session.planUserRequest = session.planUserRequest || displayName;
+
+ // Try to detect and preserve version from imported plugin
+ try {
+ // Collect PHP files to find main plugin file
+ const validFiles = [];
+ await collectValidFiles(session.workspaceDir, session.workspaceDir, validFiles, [
+ 'node_modules',
+ '.git',
+ '.data',
+ 'uploads',
+ '*.log',
+ '*.zip'
+ ]);
+
+ // Find main plugin file and extract version
+ for (const fileInfo of validFiles) {
+ if (fileInfo.fullPath.endsWith('.php')) {
+ try {
+ const content = await fs.readFile(fileInfo.fullPath, 'utf-8');
+ if (content.includes('Plugin Name:')) {
+ const versions = versionManager.extractAllVersions(content);
+ if (versions.detectedVersion) {
+ session.pluginVersion = versions.detectedVersion;
+ session.pluginVersionHistory = [{
+ version: versions.detectedVersion,
+ previousVersion: null,
+ bumpType: 'import',
+ timestamp: new Date().toISOString(),
+ fileUpdated: fileInfo.relativePath,
+ note: 'Imported from uploaded ZIP'
+ }];
+ log('Detected version from imported plugin', {
+ sessionId: session.id,
+ version: versions.detectedVersion,
+ file: fileInfo.relativePath
+ });
+ break;
+ }
+ }
+ } catch (err) {
+ // Continue to next file
+ }
+ }
+ }
+ } catch (versionError) {
+ log('Version detection failed during import', { error: String(versionError) });
+ // Don't fail the import if version detection fails
+ }
+
await persistState();
return sendJson(res, 201, { session: serializeSession(session), files });
} catch (error) {
@@ -16956,6 +17013,78 @@ async function handleExportZip(_req, res, sessionId, userId) {
}
}
+ // Version management for WordPress plugins
+ let versionUpdatedContent = null;
+ let oldVersion = null;
+ let newVersion = null;
+
+ if (mainPluginFile && pluginContent) {
+ try {
+ // Extract current version from plugin file
+ const versions = versionManager.extractAllVersions(pluginContent);
+ const detectedVersion = versions.detectedVersion;
+
+ // Determine which version to use:
+ // 1. Session version if available and valid
+ // 2. Detected version from file
+ // 3. Default to 1.0.0 if nothing found
+ let currentVersion = session.pluginVersion || detectedVersion || '1.0.0';
+
+ // If we detected a version from file but session doesn't have one, use detected
+ if (detectedVersion && !session.pluginVersion) {
+ currentVersion = detectedVersion;
+ log('Detected version from imported plugin file', {
+ file: mainPluginFile.relativePath,
+ version: detectedVersion
+ });
+ }
+
+ // Bump the version (default to patch bump)
+ // Note: In the future, this could be configurable via query parameter
+ newVersion = versionManager.bumpVersion(currentVersion, 'patch');
+
+ if (newVersion && newVersion !== currentVersion) {
+ // Update the plugin content with new version
+ versionUpdatedContent = versionManager.updateVersionInContent(
+ pluginContent,
+ currentVersion,
+ newVersion
+ );
+
+ if (versionUpdatedContent) {
+ oldVersion = currentVersion;
+
+ // Update session tracking
+ session.pluginVersion = newVersion;
+ session.lastVersionBumpType = 'patch';
+ session.pluginVersionHistory.push({
+ version: newVersion,
+ previousVersion: currentVersion,
+ bumpType: 'patch',
+ timestamp: new Date().toISOString(),
+ fileUpdated: mainPluginFile.relativePath
+ });
+
+ log('Bumped plugin version', {
+ from: currentVersion,
+ to: newVersion,
+ file: mainPluginFile.relativePath,
+ sessionId: session.id
+ });
+ }
+ } else {
+ // Keep current version if bump failed
+ session.pluginVersion = currentVersion;
+ }
+ } catch (versionError) {
+ log('Version detection/update failed, continuing with original files', {
+ error: String(versionError),
+ file: mainPluginFile?.relativePath
+ });
+ // Don't fail the export if version management fails
+ }
+ }
+
// Determine the plugin folder name
// WordPress identifies plugins by folder name + main file name
let pluginFolderName = session.pluginSlug;
@@ -16977,7 +17106,7 @@ async function handleExportZip(_req, res, sessionId, userId) {
log('Plugin folder will be normalized', { file: mainPluginFile.relativePath, existingFolder, pluginSlug: pluginFolderName });
}
}
-
+
// Determine the optimal root to avoid nested wrappers
const optimalRoot = findOptimalExportRoot(validFiles, session.workspaceDir);
@@ -17019,6 +17148,9 @@ async function handleExportZip(_req, res, sessionId, userId) {
for (const fileInfo of validFiles) {
const relativePath = path.relative(optimalRoot, fileInfo.fullPath);
+ // Check if this is the main plugin file with updated content
+ const isMainPluginFile = mainPluginFile && fileInfo.fullPath === mainPluginFile.fullPath;
+
if (wrapInPluginFolder) {
let archivePath;
@@ -17036,9 +17168,19 @@ async function handleExportZip(_req, res, sessionId, userId) {
archivePath = path.join(wrapInPluginFolder, relativePath);
}
- archive.file(fileInfo.fullPath, { name: archivePath });
+ // If this is the main plugin file and we have updated content, use it
+ if (isMainPluginFile && versionUpdatedContent) {
+ archive.append(Buffer.from(versionUpdatedContent, 'utf-8'), { name: archivePath });
+ } else {
+ archive.file(fileInfo.fullPath, { name: archivePath });
+ }
} else {
- archive.file(fileInfo.fullPath, { name: relativePath });
+ // If this is the main plugin file and we have updated content, use it
+ if (isMainPluginFile && versionUpdatedContent) {
+ archive.append(Buffer.from(versionUpdatedContent, 'utf-8'), { name: relativePath });
+ } else {
+ archive.file(fileInfo.fullPath, { name: relativePath });
+ }
}
}
@@ -17075,7 +17217,13 @@ async function handleExportZip(_req, res, sessionId, userId) {
'Content-Length': zipContent.length
});
res.end(zipContent);
- log('Export completed successfully', { filename: zipFilename, size: zipContent.length, fileCount });
+ log('Export completed successfully', {
+ filename: zipFilename,
+ size: zipContent.length,
+ fileCount,
+ version: newVersion || session.pluginVersion || 'unknown',
+ previousVersion: oldVersion || 'initial'
+ });
} catch (error) {
if (error.message && error.message.includes('size exceeds')) {
return sendJson(res, 400, { error: error.message });
diff --git a/chat/src/utils/versionManager.js b/chat/src/utils/versionManager.js
new file mode 100644
index 0000000..9dfd783
--- /dev/null
+++ b/chat/src/utils/versionManager.js
@@ -0,0 +1,309 @@
+/**
+ * Version Manager Utility
+ * Handles WordPress plugin version parsing, bumping, and detection
+ */
+
+const path = require('path');
+const fs = require('fs').promises;
+
+/**
+ * Valid semantic version regex (X.Y.Z format)
+ */
+const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
+
+/**
+ * WordPress plugin header version regex
+ * Matches: Version: 1.2.3 or Version:1.2.3
+ */
+const WP_HEADER_VERSION_REGEX = /^\s*\*?\s*Version:\s*(\d+\.\d+\.\d+)/im;
+
+/**
+ * PHP define version regex
+ * Matches: define('CONSTANT_NAME', '1.2.3') or define( "CONSTANT_NAME", "1.2.3" )
+ */
+const PHP_DEFINE_VERSION_REGEX = /define\s*\(\s*['"][A-Z_]*VERSION['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)/gi;
+
+/**
+ * PHP constant assignment regex (alternative format)
+ * Matches: const VERSION = '1.2.3';
+ */
+const PHP_CONST_VERSION_REGEX = /const\s+[A-Z_]*VERSION\s*=\s*['"](\d+\.\d+\.\d+)['"]/gi;
+
+/**
+ * Parse a version string into components
+ * @param {string} version - Version string (e.g., "1.2.3")
+ * @returns {object|null} - { major, minor, patch } or null if invalid
+ */
+function parseVersion(version) {
+ if (!version || typeof version !== 'string') return null;
+
+ const cleanVersion = version.trim();
+ if (!VERSION_REGEX.test(cleanVersion)) return null;
+
+ const [major, minor, patch] = cleanVersion.split('.').map(Number);
+ return { major, minor, patch, raw: cleanVersion };
+}
+
+/**
+ * Format version components into a string
+ * @param {number} major
+ * @param {number} minor
+ * @param {number} patch
+ * @returns {string} - Formatted version (e.g., "1.2.3")
+ */
+function formatVersion(major, minor, patch) {
+ return `${major}.${minor}.${patch}`;
+}
+
+/**
+ * Bump a version number
+ * @param {string} currentVersion - Current version (e.g., "1.2.3")
+ * @param {string} bumpType - 'major', 'minor', 'patch', or 'keep'
+ * @returns {string|null} - New version or null if invalid input
+ */
+function bumpVersion(currentVersion, bumpType = 'patch') {
+ const parsed = parseVersion(currentVersion);
+ if (!parsed) return null;
+
+ const { major, minor, patch } = parsed;
+
+ switch (bumpType.toLowerCase()) {
+ case 'major':
+ return formatVersion(major + 1, 0, 0);
+ case 'minor':
+ return formatVersion(major, minor + 1, 0);
+ case 'patch':
+ return formatVersion(major, minor, patch + 1);
+ case 'keep':
+ return currentVersion;
+ default:
+ // Default to patch bump
+ return formatVersion(major, minor, patch + 1);
+ }
+}
+
+/**
+ * Extract version from WordPress plugin header
+ * @param {string} content - PHP file content
+ * @returns {string|null} - Version string or null
+ */
+function extractHeaderVersion(content) {
+ if (!content) return null;
+ const match = content.match(WP_HEADER_VERSION_REGEX);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extract version from PHP define() constants
+ * @param {string} content - PHP file content
+ * @returns {string|null} - Version string or null
+ */
+function extractDefineVersion(content) {
+ if (!content) return null;
+ // Use non-global version of regex to properly capture groups
+ const regex = /define\s*\(\s*['"][A-Z_]*VERSION['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)/i;
+ const match = content.match(regex);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extract version from PHP const assignments
+ * @param {string} content - PHP file content
+ * @returns {string|null} - Version string or null
+ */
+function extractConstVersion(content) {
+ if (!content) return null;
+ const regex = new RegExp(PHP_CONST_VERSION_REGEX);
+ const match = regex.exec(content);
+ return match ? match[1] : null;
+}
+
+/**
+ * Find the main plugin file in a directory
+ * Searches for PHP files with WordPress plugin headers
+ * @param {string} dirPath - Directory to search
+ * @param {Array} validFiles - Optional array of file objects from collectValidFiles
+ * @returns {Promise