diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj b/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj
new file mode 100644
index 0000000..b8f4e37
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/NKS.WebDevConsole.Plugin.PostgreSQL.csproj
@@ -0,0 +1,17 @@
+
+
+ net9.0
+ enable
+ enable
+ NKS.WebDevConsole.Plugin.PostgreSQL
+ NKS.WebDevConsole.Plugin.PostgreSQL
+ true
+
+
+
+
+
+
+
+
+
diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs
new file mode 100644
index 0000000..db0bed8
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlModule.cs
@@ -0,0 +1,304 @@
+using System.Diagnostics;
+using System.Threading.Channels;
+using CliWrap;
+using CliWrap.Buffered;
+using Microsoft.Extensions.Logging;
+using NKS.WebDevConsole.Core.Interfaces;
+using NKS.WebDevConsole.Core.Models;
+using NKS.WebDevConsole.Core.Services;
+
+namespace NKS.WebDevConsole.Plugin.PostgreSQL;
+
+public sealed class PostgreSqlConfig
+{
+ public string BinariesRoot { get; set; } = Path.Combine(WdcPaths.BinariesRoot, "postgresql");
+ public string DataDir { get; set; } = Path.Combine(WdcPaths.DataRoot, "postgresql");
+ public string LogDirectory { get; set; } = Path.Combine(WdcPaths.LogsRoot, "postgresql");
+ public string? PostgresPath { get; set; }
+ public string? PgCtlPath { get; set; }
+ public string? InitDbPath { get; set; }
+ public string? PgIsReadyPath { get; set; }
+ public int Port { get; set; } = 5432;
+ public int GracefulTimeoutSecs { get; set; } = 15;
+}
+
+public sealed class PostgreSqlModule : IServiceModule, IAsyncDisposable
+{
+ public string ServiceId => "postgresql";
+ public string DisplayName => "PostgreSQL";
+ public ServiceType Type => ServiceType.Database;
+
+ private readonly ILogger _logger;
+ private readonly PostgreSqlConfig _config;
+ private readonly object _stateLock = new();
+ private readonly Channel _logChannel = Channel.CreateBounded(
+ new BoundedChannelOptions(2000) { FullMode = BoundedChannelFullMode.DropOldest });
+
+ private Process? _process;
+ private ServiceState _state = ServiceState.Stopped;
+ private DateTime? _startTime;
+ private string LogFile => Path.Combine(_config.LogDirectory, "postgresql.log");
+
+ public PostgreSqlModule(ILogger logger, PostgreSqlConfig? config = null)
+ {
+ _logger = logger;
+ _config = config ?? new PostgreSqlConfig();
+ }
+
+ public Task InitializeAsync(CancellationToken ct)
+ {
+ Directory.CreateDirectory(_config.DataDir);
+ Directory.CreateDirectory(_config.LogDirectory);
+ DetectBinaries();
+
+ if (!string.IsNullOrEmpty(_config.PostgresPath) && File.Exists(_config.PostgresPath))
+ _logger.LogInformation("Using PostgreSQL: {Path}", _config.PostgresPath);
+ else
+ _logger.LogWarning("postgres executable not found");
+
+ return Task.CompletedTask;
+ }
+
+ public Task ValidateConfigAsync(CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(_config.PostgresPath) || !File.Exists(_config.PostgresPath))
+ return Task.FromResult(new ValidationResult(false, "postgres executable not found"));
+ if (string.IsNullOrEmpty(_config.PgCtlPath) || !File.Exists(_config.PgCtlPath))
+ return Task.FromResult(new ValidationResult(false, "pg_ctl executable not found"));
+ if (string.IsNullOrEmpty(_config.InitDbPath) || !File.Exists(_config.InitDbPath))
+ return Task.FromResult(new ValidationResult(false, "initdb executable not found"));
+ if (string.IsNullOrEmpty(_config.PgIsReadyPath) || !File.Exists(_config.PgIsReadyPath))
+ return Task.FromResult(new ValidationResult(false, "pg_isready executable not found"));
+ if (_config.Port is < 1 or > 65535)
+ return Task.FromResult(new ValidationResult(false, $"Invalid PostgreSQL port: {_config.Port}"));
+ return Task.FromResult(new ValidationResult(true));
+ }
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ lock (_stateLock)
+ {
+ if (_state is ServiceState.Running or ServiceState.Starting)
+ throw new InvalidOperationException($"PostgreSQL is already {_state}.");
+ _state = ServiceState.Starting;
+ }
+
+ try
+ {
+ var validation = await ValidateConfigAsync(ct);
+ if (!validation.IsValid)
+ throw new InvalidOperationException($"Config validation failed: {validation.ErrorMessage}");
+
+ await EnsureDataDirInitializedAsync(ct);
+
+ var args = new[]
+ {
+ "-D", _config.DataDir,
+ "-l", LogFile,
+ "-o", $"-p {_config.Port} -h 127.0.0.1",
+ "start"
+ };
+ var result = await Cli.Wrap(_config.PgCtlPath!)
+ .WithArguments(args)
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteBufferedAsync(ct);
+ PublishBuffered(result);
+
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException($"pg_ctl start exited {result.ExitCode}: {result.StandardError.Trim()}");
+
+ await WaitUntilReadyAsync(ct);
+ _process = TryAttachPostgresProcess();
+ if (_process is not null)
+ {
+ _process.EnableRaisingEvents = true;
+ _process.Exited += OnProcessExited;
+ DaemonJobObject.AssignProcess(_process);
+ }
+
+ _startTime = DateTime.UtcNow;
+ lock (_stateLock) _state = ServiceState.Running;
+ _logger.LogInformation("PostgreSQL running on port {Port}", _config.Port);
+ }
+ catch
+ {
+ lock (_stateLock)
+ {
+ if (_state != ServiceState.Crashed)
+ _state = ServiceState.Stopped;
+ }
+ throw;
+ }
+ }
+
+ public async Task StopAsync(CancellationToken ct)
+ {
+ lock (_stateLock)
+ {
+ if (_state == ServiceState.Stopped) return;
+ _state = ServiceState.Stopping;
+ }
+
+ if (!string.IsNullOrEmpty(_config.PgCtlPath) && File.Exists(_config.PgCtlPath))
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(_config.GracefulTimeoutSecs));
+ var result = await Cli.Wrap(_config.PgCtlPath)
+ .WithArguments(new[] { "-D", _config.DataDir, "stop", "-m", "fast" })
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteBufferedAsync(cts.Token);
+ PublishBuffered(result);
+ }
+
+ _process?.Dispose();
+ _process = null;
+ _startTime = null;
+ lock (_stateLock) _state = ServiceState.Stopped;
+ }
+
+ public async Task ReloadAsync(CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(_config.PgCtlPath) || !File.Exists(_config.PgCtlPath))
+ throw new InvalidOperationException("pg_ctl executable not found");
+
+ var result = await Cli.Wrap(_config.PgCtlPath)
+ .WithArguments(new[] { "-D", _config.DataDir, "reload" })
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteBufferedAsync(ct);
+ PublishBuffered(result);
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException($"pg_ctl reload exited {result.ExitCode}: {result.StandardError.Trim()}");
+ }
+
+ public Task GetStatusAsync(CancellationToken ct)
+ {
+ ServiceState state;
+ int? pid;
+ lock (_stateLock)
+ {
+ if (_state is ServiceState.Running or ServiceState.Starting)
+ _process ??= TryAttachPostgresProcess();
+ if (_state is ServiceState.Running or ServiceState.Starting && _process is null)
+ _state = ServiceState.Crashed;
+ state = _state;
+ pid = _process?.Id;
+ }
+
+ var (cpu, memory) = ProcessMetricsSampler.Sample(_process);
+ var uptime = _startTime.HasValue ? DateTime.UtcNow - _startTime.Value : TimeSpan.Zero;
+ return Task.FromResult(new ServiceStatus(ServiceId, DisplayName, state, pid, cpu, memory, uptime));
+ }
+
+ public async Task> GetLogsAsync(int lines, CancellationToken ct)
+ {
+ var result = new List(lines);
+ if (File.Exists(LogFile))
+ {
+ var tail = File.ReadLines(LogFile).TakeLast(lines).ToArray();
+ result.AddRange(tail);
+ }
+
+ while (result.Count < lines && _logChannel.Reader.TryRead(out var line))
+ result.Add(line);
+
+ return await Task.FromResult(result);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _process?.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ private void DetectBinaries()
+ {
+ if (!Directory.Exists(_config.BinariesRoot)) return;
+ var ext = OperatingSystem.IsWindows() ? ".exe" : "";
+ var versionDirs = Directory.GetDirectories(_config.BinariesRoot)
+ .Where(d => !Path.GetFileName(d).StartsWith('.') && !Path.GetFileName(d).EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(d => Path.GetFileName(d), SemverVersionComparer.Instance);
+
+ foreach (var dir in versionDirs)
+ {
+ var bin = Path.Combine(dir, "bin");
+ var postgres = Path.Combine(bin, "postgres" + ext);
+ var pgCtl = Path.Combine(bin, "pg_ctl" + ext);
+ var initDb = Path.Combine(bin, "initdb" + ext);
+ var pgIsReady = Path.Combine(bin, "pg_isready" + ext);
+ if (!File.Exists(postgres) || !File.Exists(pgCtl) || !File.Exists(initDb) || !File.Exists(pgIsReady))
+ continue;
+
+ _config.PostgresPath = postgres;
+ _config.PgCtlPath = pgCtl;
+ _config.InitDbPath = initDb;
+ _config.PgIsReadyPath = pgIsReady;
+ return;
+ }
+ }
+
+ private async Task EnsureDataDirInitializedAsync(CancellationToken ct)
+ {
+ if (File.Exists(Path.Combine(_config.DataDir, "PG_VERSION")))
+ return;
+
+ var result = await Cli.Wrap(_config.InitDbPath!)
+ .WithArguments(new[] { "-D", _config.DataDir, "-A", "trust", "-U", "postgres", "--no-locale" })
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteBufferedAsync(ct);
+ PublishBuffered(result);
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException($"initdb exited {result.ExitCode}: {result.StandardError.Trim()}");
+ }
+
+ private async Task WaitUntilReadyAsync(CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20);
+ while (DateTime.UtcNow < deadline)
+ {
+ var result = await Cli.Wrap(_config.PgIsReadyPath!)
+ .WithArguments(new[] { "-h", "127.0.0.1", "-p", _config.Port.ToString(), "-U", "postgres" })
+ .WithValidation(CommandResultValidation.None)
+ .ExecuteBufferedAsync(ct);
+ PublishBuffered(result);
+ if (result.ExitCode == 0) return;
+ await Task.Delay(500, ct);
+ }
+ throw new TimeoutException($"PostgreSQL did not become ready on port {_config.Port} within 20 seconds.");
+ }
+
+ private Process? TryAttachPostgresProcess()
+ {
+ var pidFile = Path.Combine(_config.DataDir, "postmaster.pid");
+ if (!File.Exists(pidFile)) return null;
+ var first = File.ReadLines(pidFile).FirstOrDefault();
+ return int.TryParse(first, out var pid) ? TryGetProcess(pid) : null;
+ }
+
+ private static Process? TryGetProcess(int pid)
+ {
+ try { return Process.GetProcessById(pid); }
+ catch { return null; }
+ }
+
+ private void OnProcessExited(object? sender, EventArgs e)
+ {
+ lock (_stateLock)
+ {
+ if (_state is ServiceState.Stopping or ServiceState.Stopped)
+ return;
+ _state = ServiceState.Crashed;
+ _startTime = null;
+ }
+ }
+
+ private void PublishBuffered(BufferedCommandResult result)
+ {
+ foreach (var line in SplitLines(result.StandardOutput))
+ _logChannel.Writer.TryWrite(line);
+ foreach (var line in SplitLines(result.StandardError))
+ _logChannel.Writer.TryWrite("[ERR] " + line);
+ }
+
+ private static IEnumerable SplitLines(string text) =>
+ text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
+}
diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs
new file mode 100644
index 0000000..a7d5a49
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/PostgreSqlPlugin.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NKS.WebDevConsole.Core.Interfaces;
+using NKS.WebDevConsole.Core.Models;
+using NKS.WebDevConsole.Plugin.SDK;
+
+namespace NKS.WebDevConsole.Plugin.PostgreSQL;
+
+public sealed class PostgreSqlPlugin : IWdcPlugin, IFrontendPanelProvider
+{
+ public string Id => "nks.wdc.postgresql";
+ public string DisplayName => "PostgreSQL";
+ public string Version => "1.0.0";
+
+ private PostgreSqlModule? _module;
+ private IDisposable? _binaryInstalledSub;
+
+ public void Initialize(IServiceCollection services, IPluginContext context)
+ {
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ }
+
+ public async Task StartAsync(IPluginContext context, CancellationToken ct)
+ {
+ var logger = context.GetLogger();
+ logger.LogInformation("PostgreSQL plugin v{Version} loaded", Version);
+
+ _module = context.ServiceProvider.GetRequiredService();
+ await _module.InitializeAsync(ct);
+
+ var bus = context.ServiceProvider.GetService(typeof(IBinaryInstalledEventBus))
+ as IBinaryInstalledEventBus;
+ _binaryInstalledSub = bus?.Subscribe(async evt =>
+ {
+ if (!string.Equals(evt.App, "postgresql", StringComparison.OrdinalIgnoreCase)) return;
+ logger.LogInformation(
+ "BinaryInstalled postgresql {Version} -> re-initializing PostgreSQL module", evt.Version);
+ if (_module is not null)
+ await _module.InitializeAsync(CancellationToken.None);
+ });
+ }
+
+ public async Task StopAsync(CancellationToken ct)
+ {
+ _binaryInstalledSub?.Dispose();
+ _binaryInstalledSub = null;
+ if (_module is not null)
+ await _module.StopAsync(ct);
+ }
+
+ public PluginUiDefinition GetUiDefinition() =>
+ new UiSchemaBuilder(Id)
+ .Category("Databases")
+ .Icon("el-icon-coin")
+ .SetServiceCategory("db", "postgresql")
+ .AddServiceCard("postgresql")
+ .AddConfigEditor("postgresql")
+ .AddLogViewer("postgresql")
+ .AddMetricsChart("postgresql")
+ .Build();
+}
diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/README.md b/NKS.WebDevConsole.Plugin.PostgreSQL/README.md
new file mode 100644
index 0000000..bc48d69
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/README.md
@@ -0,0 +1,17 @@
+# PostgreSQL Plugin
+
+Manages a local PostgreSQL instance from WDC-managed binaries.
+
+## Capabilities
+
+- Detects `~/.wdc/binaries/postgresql//bin/postgres`.
+- Initializes `~/.wdc/data/postgresql` with `initdb`.
+- Starts and stops PostgreSQL through `pg_ctl`.
+- Checks readiness with `pg_isready`.
+- Streams the PostgreSQL log from `~/.wdc/logs/postgresql/postgresql.log`.
+
+Install binaries through WDC first:
+
+```bash
+wdc binaries install postgresql@18.3
+```
diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg b/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg
new file mode 100644
index 0000000..60ce8bf
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/Resources/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json b/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json
new file mode 100644
index 0000000..f5de5d5
--- /dev/null
+++ b/NKS.WebDevConsole.Plugin.PostgreSQL/plugin.json
@@ -0,0 +1,12 @@
+{
+ "id": "nks.wdc.postgresql",
+ "name": "PostgreSQL",
+ "version": "1.0.0",
+ "description": "PostgreSQL service module - manages postgres lifecycle with initdb, pg_ctl, pg_isready, generated data directory, and local development defaults.",
+ "author": "NKS Hub",
+ "category": "Databases",
+ "serviceId": "postgresql",
+ "serviceType": "Database",
+ "defaultPort": 5432,
+ "entryAssembly": "NKS.WebDevConsole.Plugin.PostgreSQL.dll"
+}