diff --git a/benchmate/api/actions/bench_start.py b/benchmate/api/actions/bench_start.py
new file mode 100644
index 0000000..7a4cfa7
--- /dev/null
+++ b/benchmate/api/actions/bench_start.py
@@ -0,0 +1,64 @@
+import os
+import platform
+import subprocess
+
+import frappe
+
+
+@frappe.whitelist()
+def execute(bench_name: str, bench_path: str):
+ """
+ Start all services of a given bench in the background.
+ This will simulate `bench start` using nohup and run it detached.
+ """
+ try:
+ if not bench_path:
+ frappe.throw("bench_path parameter is required.", frappe.ValidationError)
+
+ bench_path = os.path.abspath(bench_path)
+
+ if not os.path.isdir(bench_path):
+ frappe.throw(f"Invalid bench path: {bench_path}", frappe.ValidationError)
+
+ is_linux = "Linux" in platform.system()
+
+ if is_linux:
+ # nohup will detach the bench start process so it survives API exit
+ log_file = os.path.join(bench_path, "bench_start.log")
+ cmd = f"nohup {bench_path}/env/bin/bench start > {log_file} 2>&1 &"
+
+ subprocess.Popen(
+ cmd,
+ shell=True,
+ cwd=bench_path,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ preexec_fn=os.setpgrp,
+ )
+
+ else:
+ # Windows fallback (though not common for benches)
+ cmd = "bench start"
+ subprocess.Popen(
+ cmd,
+ shell=True,
+ cwd=bench_path,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ return {
+ "success": True,
+ "message": f"Bench '{bench_name}' services start command issued successfully.",
+ "data": {"bench_path": bench_path},
+ }
+
+ except frappe.ValidationError as ve:
+ return {"success": False, "message": str(ve), "data": None}
+
+ except Exception as e:
+ return {
+ "success": False,
+ "message": f"Error starting bench: {e}",
+ "data": None,
+ }
diff --git a/benchmate/api/actions/bench_stop.py b/benchmate/api/actions/bench_stop.py
new file mode 100644
index 0000000..3f9a192
--- /dev/null
+++ b/benchmate/api/actions/bench_stop.py
@@ -0,0 +1,172 @@
+import errno
+import glob
+import json
+import os
+import platform
+import socket
+import time
+
+import frappe
+
+
+def read_redis_ports(config_dir):
+ """
+ Reads all `redis_*.conf` files from the given config directory
+ and extracts the 'port' values used by Redis instances.
+ """
+ ports = []
+ # ? Loop through all redis_*.conf files inside config directory
+ for conf_file in glob.glob(os.path.join(config_dir, "redis_*.conf")):
+ try:
+ with open(conf_file) as f:
+ for line in f:
+ # ? Extract port number if line starts with 'port'
+ if line.strip().startswith("port"):
+ port = int(line.strip().split()[1])
+ ports.append(port)
+ break
+ except Exception:
+ # ? Ignore faulty/missing config files and continue checking others
+ continue
+ # ? Return unique ports only
+ return list(set(ports))
+
+
+def read_site_ports(sites_dir):
+ """
+ Reads `site_config.json` for each site inside the sites directory
+ and extracts `webserver_port` and `socketio_port` values.
+ """
+ ports = []
+ # ? Ensure that sites folder actually exists
+ if not os.path.isdir(sites_dir):
+ frappe.throw(
+ f"Sites directory not found at {sites_dir}",
+ frappe.DoesNotExistError,
+ )
+
+ # ? Iterate over all sites present inside the sites directory
+ for site_name in os.listdir(sites_dir):
+ site_config_path = os.path.join(sites_dir, site_name, "site_config.json")
+ if os.path.isfile(site_config_path):
+ try:
+ with open(site_config_path) as f:
+ data = json.load(f)
+ # ? Collect webserver_port if present
+ if "webserver_port" in data:
+ ports.append(int(data["webserver_port"]))
+ # ? Collect socketio_port if present
+ if "socketio_port" in data:
+ ports.append(int(data["socketio_port"]))
+ except Exception:
+ # ? Continue checking other sites if a site config fails
+ continue
+ # ? Return unique site ports only
+ return list(set(ports))
+
+
+def stop_port(port, is_linux):
+ """
+ Attempts to stop services running on a specific port.
+
+ Args:
+ port (int): Port number to stop.
+ is_linux (bool): Whether the system is Linux based.
+
+ Returns:
+ bool: True if a port was in use and a stop command was attempted.
+ False if port was free or no action was needed.
+ """
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ # ? Try binding to port; if succeeds, port is free (no service running)
+ sock.bind(("127.0.0.1", port))
+ # ? Port not in use
+ return False
+ except OSError as e:
+ # ? If port is already in use
+ if e.errno == errno.EADDRINUSE:
+ # ? If port seems to be Redis (within range), attempt graceful shutdown
+ if 1100 <= port <= 14000:
+ os.system(f"echo 'shutdown' | redis-cli -h 127.0.0.1 -p {port} 2>/dev/null")
+ # ? Give Redis process some time to shutdown
+ time.sleep(1)
+
+ # ? Force kill remaining process using OS-based commands
+ if is_linux:
+ os.system(f"fuser {port}/tcp -k") # Linux command to kill process on a port
+ else:
+ # ? macOS fallback
+ os.system(f"lsof -i tcp:{port} | grep -v PID | awk '{{print $2}}' | xargs kill")
+ return True
+ else:
+ # ? Unexpected socket error
+ return False
+ finally:
+ # ? Ensure socket is closed
+ sock.close()
+
+
+@frappe.whitelist()
+def execute(bench_name: str, bench_path: str):
+ """
+ API method to stop bench services for the bench located at the given path.
+
+ Args:
+ bench_path (str): Absolute or relative path to the bench directory.
+
+ Returns:
+ dict: Status message and list of stopped ports enclosed in `data`.
+ On error, uses frappe.throw for validation or returns structured error dict.
+ """
+ try:
+ # ? Validate input parameter
+ if not bench_path:
+ frappe.throw(
+ "bench_path parameter is required.",
+ frappe.ValidationError,
+ )
+
+ bench_path = os.path.abspath(bench_path)
+ config_dir = os.path.join(bench_path, "config")
+ sites_dir = os.path.join(bench_path, "sites")
+
+ # ? Validate essential folders and throw if missing
+ if not os.path.isdir(config_dir) or not os.path.isdir(sites_dir):
+ frappe.throw(
+ f"Invalid bench path or missing 'config' or 'sites' folder: {bench_path}",
+ frappe.ValidationError,
+ )
+
+ is_linux = "Linux" in platform.system()
+
+ # ? Gather all related ports from Redis + Sites configurations
+ redis_ports = read_redis_ports(config_dir)
+ site_ports = read_site_ports(sites_dir)
+ all_ports = list(set(redis_ports + site_ports))
+
+ if not all_ports:
+ frappe.throw("No bench service ports found to stop.")
+
+ # ? Stop all detected services running on collected ports
+ for port in all_ports:
+ stop_port(port, is_linux)
+
+ # ? Return success response with data
+ return {
+ "success": True,
+ "message": f"{bench_name} Bench services are stopped successfully.",
+ "data": {"stopped_ports": all_ports},
+ }
+
+ except frappe.ValidationError as ve:
+ # ? Raised by frappe.throw validation
+ return {"success": False, "message": str(ve), "data": None}
+
+ except Exception as e:
+ # ? General unexpected error
+ return {
+ "success": False,
+ "message": f"Error stopping bench: {e!s}",
+ "data": None,
+ }
diff --git a/benchmate/api/actions/create_site.py b/benchmate/api/actions/create_site.py
new file mode 100644
index 0000000..f6dfdc2
--- /dev/null
+++ b/benchmate/api/actions/create_site.py
@@ -0,0 +1,164 @@
+import os
+import subprocess
+import time
+
+import frappe
+
+from benchmate.api.utils import get_benchmate_settings
+
+
+def update_log_status(docname, new_text=None, status=None):
+ """
+ ? Update the BM Site Creation Logs record for a given docname.
+ ? Appends log text and/or updates the status field, committing immediately.
+ """
+ try:
+ log_doc = frappe.get_doc("BM Site Creation Logs", docname)
+
+ # ? Append new log text if provided
+ if new_text:
+ updated_log = (log_doc.log or "") + new_text
+ log_doc.db_set("log", updated_log, update_modified=False)
+
+ # ? Update status if provided (e.g., "In Process", "Success", "Error")
+ if status:
+ log_doc.db_set("status", status, update_modified=False)
+
+ frappe.db.commit()
+ log_doc.reload()
+ except Exception as e:
+ frappe.log_error(f"Error updating BM Site Creation Logs: {e}", "BenchMate SiteCreationLogs")
+
+
+def create_site_background(bench_path: str, site_name: str, sudo_password: str, mysql_root_password: str):
+ """
+ Background task to create a new Frappe site inside a given bench.
+ Captures real-time logs into BM Site Creation Logs doctype,
+ and cleans up temporary log files after completion.
+ """
+ bench_path = os.path.abspath(bench_path)
+ log_file = os.path.join(bench_path, f"bench_new_site_{site_name}.log")
+
+ # ? Create a unique BM Site Creation Logs record for tracking
+ log_timestamp = int(time.time())
+ log_name = f"{site_name}-{log_timestamp}"
+ if not frappe.db.exists("BM Site Creation Logs", log_name):
+ frappe.get_doc(
+ {
+ "doctype": "BM Site Creation Logs",
+ "site_name": site_name,
+ "log": "",
+ "log_timestamp": log_timestamp,
+ "status": "In Process",
+ }
+ ).insert(ignore_permissions=True)
+ frappe.db.commit()
+
+ # ? Command to create a new site with root DB password and default admin password
+ cmd = [
+ "sudo",
+ "-S",
+ "bench",
+ "new-site",
+ site_name,
+ "--db-root-password",
+ mysql_root_password,
+ "--admin-password",
+ "root",
+ "--verbose",
+ ]
+
+ # ? Launch subprocess and redirect stdout/stderr into a log file
+ with open(log_file, "w") as f:
+ proc = subprocess.Popen(
+ cmd,
+ cwd=bench_path,
+ stdin=subprocess.PIPE,
+ stdout=f,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ # ? Send sudo password to subprocess
+ proc.stdin.write(sudo_password + "\n")
+ proc.stdin.flush()
+ proc.stdin.close()
+
+ # ? Tail the log file and update BM Site Creation Logs in real-time
+ try:
+ with open(log_file) as f:
+ f.seek(0, os.SEEK_END) # ? Move to end for live tailing
+ while proc.poll() is None:
+ where = f.tell()
+ line = f.readline()
+ if not line:
+ time.sleep(1)
+ f.seek(where)
+ else:
+ update_log_status(log_name, new_text=line)
+
+ # ? Capture any remaining output after process finishes
+ remaining = f.read()
+ if remaining:
+ update_log_status(log_name, new_text=remaining)
+
+ # ? Update status based on exit code
+ if proc.returncode == 0:
+ update_log_status(log_name, status="Success")
+ else:
+ update_log_status(log_name, status="Error")
+
+ except Exception as e:
+ frappe.log_error(f"Error tailing bench new-site log: {e}", "BenchMate SiteCreationLogs")
+ update_log_status(log_name, status="Error")
+
+ finally:
+ # ? Always clean up the temporary log file
+ try:
+ if os.path.exists(log_file):
+ os.remove(log_file)
+ except Exception as cleanup_error:
+ frappe.log_error(
+ f"Failed to remove temp log file {log_file}: {cleanup_error}",
+ "BenchMate SiteCreationLogs",
+ )
+
+
+@frappe.whitelist()
+def execute(bench_path: str, site_name: str):
+ """
+ ? Public API method (whitelisted) to enqueue site creation.
+ ? Validates input and enqueues the background site creation task.
+ """
+ if not bench_path or not site_name:
+ frappe.throw("bench_path and site_name are required", frappe.ValidationError)
+
+ # ? Fetch global BenchMate settings (sudo password, DB password)
+ settings = get_benchmate_settings()
+ sudo_password = settings.get("sudo_password")
+ mysql_root_password = settings.get("db_password")
+
+ # ? Validate required passwords are configured
+ if not sudo_password:
+ frappe.throw("Sudo password not configured", frappe.ValidationError)
+ if not mysql_root_password:
+ frappe.throw("MySQL root password not configured", frappe.ValidationError)
+
+ # ? Enqueue background task for long execution
+ try:
+ frappe.enqueue(
+ create_site_background,
+ queue="long",
+ timeout=3600,
+ bench_path=bench_path,
+ site_name=site_name,
+ sudo_password=sudo_password,
+ mysql_root_password=mysql_root_password,
+ )
+ except Exception as e:
+ frappe.throw(f"Failed to enqueue site creation: {e!s}")
+
+ return {
+ "success": True,
+ "message": f"Creating {site_name} in background Check BM Site Creation Logs for more details.",
+ "data": {"bench_path": bench_path},
+ }
diff --git a/benchmate/benchmate/doctype/bm_bench/bm_bench.js b/benchmate/benchmate/doctype/bm_bench/bm_bench.js
index c1cd975..22a74f3 100644
--- a/benchmate/benchmate/doctype/bm_bench/bm_bench.js
+++ b/benchmate/benchmate/doctype/bm_bench/bm_bench.js
@@ -1,8 +1,162 @@
// Copyright (c) 2025, Karan Mistry and contributors
// For license information, please see license.txt
-// frappe.ui.form.on("BM Bench", {
-// refresh(frm) {
+frappe.ui.form.on("BM Bench", {
+ refresh: function (frm) {
+ // ? Function to add bench actions
+ addBenchActions(frm);
+ },
+});
-// },
-// });
+// ? Function to add bench actions
+function addBenchActions(frm) {
+ // ? Add "Create Site" button and pair it with handler
+ frm.add_custom_button(
+ __("Create Site"),
+ function () {
+ createSite(frm);
+ },
+ __("Actions")
+ );
+
+ // ? Add "Start Bench" button and pair it with handler
+ frm.add_custom_button(
+ __("Start Bench"),
+ function () {
+ startBench(frm);
+ },
+ __("Actions")
+ );
+
+ // ? Add "Stop Bench" button and pair it with handler
+ frm.add_custom_button(
+ __("Stop Bench"),
+ function () {
+ stopBench(frm);
+ },
+ __("Actions")
+ );
+}
+
+function createSite(frm) {
+ let d = new frappe.ui.Dialog({
+ title: __("Create New Site"),
+ fields: [
+ {
+ fieldtype: "Data",
+ label: __("Site Name"),
+ fieldname: "site_name",
+ reqd: 1,
+ },
+ ],
+ primary_action_label: __("Create"),
+ primary_action(values) {
+ d.hide();
+ frappe.call({
+ method: "benchmate.api.actions.create_site.execute",
+ args: {
+ bench_path: frm.doc.path,
+ site_name: values.site_name,
+ },
+ freeze: true,
+ freeze_message: `Creating Site ${values.site_name}...`,
+ callback: function (r) {
+ // ? If success show success message
+ if (r.message.success) {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "green",
+ },
+ 5
+ );
+ }
+
+ // ? If error show error message
+ else {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "red",
+ },
+ 5
+ );
+ }
+ },
+ });
+ },
+ });
+ d.show();
+}
+
+// ? Function to handle Start Bench action
+function startBench(frm) {
+ frappe.call({
+ method: "benchmate.api.actions.bench_start.execute",
+ args: {
+ bench_name: frm.doc.name,
+ bench_path: frm.doc.path,
+ },
+ freeze: true,
+ freeze_message: "Starting Bench...",
+ callback: function (r) {
+ // ? If success show success message
+ if (r.message.success) {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "green",
+ },
+ 5
+ );
+ }
+
+ // ? If error show error message
+ else {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "red",
+ },
+ 5
+ );
+ }
+ },
+ });
+}
+
+// ? Function to handle Stop Bench action
+function stopBench(frm) {
+ frappe.call({
+ method: "benchmate.api.actions.bench_stop.execute",
+ args: {
+ bench_name: frm.doc.name,
+ bench_path: frm.doc.path,
+ },
+ freeze: true,
+ freeze_message: "Stopping Bench...",
+ callback: function (r) {
+ // ? If success show success message
+ if (r.message.success) {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "green",
+ },
+ 5
+ );
+ }
+
+ // ? If error show error message
+ else {
+ frappe.show_alert(
+ {
+ message: __(r.message.message),
+ indicator: "red",
+ },
+ 5
+ );
+ }
+ },
+ });
+}
diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/__init__.py b/benchmate/benchmate/doctype/bm_site_creation_logs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js
new file mode 100644
index 0000000..57d60fc
--- /dev/null
+++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, Karan Mistry and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("BM Site Creation Logs", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json
new file mode 100644
index 0000000..e8c4c71
--- /dev/null
+++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.json
@@ -0,0 +1,102 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "format:{site_name}-{log_timestamp}",
+ "creation": "2025-08-27 02:56:40.293176",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "site_name",
+ "column_break_bkvd",
+ "status",
+ "log_timestamp",
+ "section_break_jlrx",
+ "log"
+ ],
+ "fields": [
+ {
+ "fieldname": "column_break_bkvd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_jlrx",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "log",
+ "fieldtype": "Long Text",
+ "in_list_view": 1,
+ "label": "Log",
+ "read_only": 1
+ },
+ {
+ "default": "In Process",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "\nIn Process\nSuccess\nError",
+ "read_only": 1
+ },
+ {
+ "fieldname": "site_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Site Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "log_timestamp",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Log Timestamp",
+ "read_only": 1,
+ "unique": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-08-27 03:39:40.158232",
+ "modified_by": "Administrator",
+ "module": "BenchMate",
+ "name": "BM Site Creation Logs",
+ "naming_rule": "Expression",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [
+ {
+ "color": "Blue",
+ "title": "In Process"
+ },
+ {
+ "color": "Green",
+ "title": "Success"
+ },
+ {
+ "color": "Red",
+ "title": "Error"
+ }
+ ],
+ "title_field": "site_name",
+ "track_seen": 1
+}
diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py
new file mode 100644
index 0000000..1639f9c
--- /dev/null
+++ b/benchmate/benchmate/doctype/bm_site_creation_logs/bm_site_creation_logs.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, Karan Mistry and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BMSiteCreationLogs(Document):
+ pass
diff --git a/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py b/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py
new file mode 100644
index 0000000..8169f2c
--- /dev/null
+++ b/benchmate/benchmate/doctype/bm_site_creation_logs/test_bm_site_creation_logs.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, Karan Mistry and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestBMSiteCreationLogs(IntegrationTestCase):
+ """
+ Integration tests for BMSiteCreationLogs.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json b/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json
index 7380294..2b3bbdf 100644
--- a/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json
+++ b/benchmate/benchmate/doctype/bm_sync_log/bm_sync_log.json
@@ -28,7 +28,7 @@
"read_only": 1
},
{
- "default": "Log",
+ "default": "Error",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
@@ -48,7 +48,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2025-08-18 02:47:10.030458",
+ "modified": "2025-08-27 03:00:11.397603",
"modified_by": "Administrator",
"module": "BenchMate",
"name": "BM Sync Log",