sql-manager is a terminal-first SQL Server, PostgreSQL, and MySQL/MariaDB management tool built on .NET 10. It can run as a command-line utility or as a full-screen Terminal.Gui application.
- Manage multiple SQL Server, PostgreSQL, and MySQL/MariaDB connections from one
sql-config.json - Add, edit, and remove connections; removed entries move to the trash bin instead of disappearing
- Create and remove databases, create users, update passwords, show users and databases, and manage roles
- Inspect a server without leaving the tool: database sizes, table listings with row counts, active sessions, and an ad-hoc query console
- Use the CLI for scripting, the TUI with
sql-manager -term, or the browser UI withsql-manager -ui; a baresql-managerasks which you want - Emit machine-readable output with
--json, or trim text output to failures and data with--quiet - Configure provider-aware connection settings per server: port, admin database, SQL Server trust mode, PostgreSQL SSL mode, MySQL SSL mode, pooling, MySQL public key retrieval, and per-server timeouts
- Protect the config at rest with Argon2id-derived encryption and AES-256-GCM, with migration support for older encrypted config formats
- Write the config atomically and keep the previous revision as a
sql-config.json.baksibling - Track selected connection, theme preference, version history, and deleted config entries in a built-in trash bin
- Build and publish self-contained single-file binaries for Windows, macOS, Linux, and musl targets
Treat sql-config.json as sensitive data.
When config encryption is disabled, stored admin and user passwords may be written in plaintext. When config encryption is enabled, the full config payload is encrypted at rest and stored connection strings remain masked.
Every save writes to a temporary file and then replaces the config, keeping the previous revision as sql-config.json.bak. That backup holds the same secrets as the config itself, so treat it with the same care and include it in any cleanup.
The web UI binds to loopback only and never listens on an external interface. Each run mints a single-use session token that is printed with the URL and required on every API call, so the printed URL is itself a credential: anyone who has it can administer your servers for as long as the process runs. Do not paste it into a chat, an issue, or a shared terminal recording.
src/SqlManager: current .NET 10 applicationsrc/SqlManager/Web: web UI host, endpoints, and the embedded front endtests/SqlManager.Tests: xUnit v3 test projectglobal.json: optsdotnet testinto the Microsoft.Testing.Platform runner used by xUnit v3build.ps1: local build and publish helpertest.ps1: restore and test helpersql-manager.ps1: older PowerShell implementation retained in the repo; the primary app is the .NET project undersrc/SqlManager
- .NET 10 SDK to build or run from source
- PowerShell 7 to use
build.ps1andtest.ps1 - Network access to the target SQL Server, PostgreSQL, MySQL, or MariaDB host
- No .NET installation on the target machine when using self-contained publish output
Choose a front end (terminal or browser) at startup:
dotnet run --project .\src\SqlManager\SqlManager.csproj --Or go straight to one:
dotnet run --project .\src\SqlManager\SqlManager.csproj -- -term
dotnet run --project .\src\SqlManager\SqlManager.csproj -- -uiShow help:
dotnet run --project .\src\SqlManager\SqlManager.csproj -- helpShow version information:
dotnet run --project .\src\SqlManager\SqlManager.csproj -- versionAfter publishing, the Windows binary is sql-manager.exe. On Linux and macOS self-contained publishes, the binary is sql-manager.
The default config path is sql-config.json next to the executable.
The app stores more metadata than older versions did. A shortened config example looks like this, and a fuller three-provider sample is available at docs/sample-sql-config.json:
{
"selectedServerName": "1",
"themeName": "iTerm2 Tango Dark",
"encryptPasswords": true,
"encryptionKey": "<unlock-password-verifier>",
"timeouts": {
"connectionTimeoutSeconds": 15,
"commandTimeoutSeconds": 30
},
"servers": [
{
"serverIdentifier": "1",
"displayName": "Primary SQL Server",
"serverName": "sql01.contoso.local",
"provider": "sqlserver",
"port": 1433,
"adminDatabase": "master",
"adminUsername": "sa",
"adminPassword": "<encrypted-or-plain>",
"sqlServerTrustMode": "false",
"connectionTimeoutSeconds": 15,
"commandTimeoutSeconds": 30,
"encrypted": true,
"databases": [
{
"databaseName": "LabDB",
"users": [
{
"username": "LabDBUser",
"password": "<encrypted-or-plain>",
"encrypted": true,
"roles": [
"db_owner",
"db_datareader"
],
"connectionString": "Server=tcp:sql01.contoso.local,1433;Initial Catalog=LabDB;User ID=LabDBUser;Password=********;Encrypt=False;TrustServerCertificate=False;"
}
]
}
]
},
{
"serverIdentifier": "2",
"displayName": "Primary PostgreSQL",
"serverName": "pg01.contoso.local",
"provider": "postgresql",
"port": 5432,
"adminDatabase": "postgres",
"adminUsername": "postgres",
"adminPassword": "<encrypted-or-plain>",
"postgreSqlSslMode": "require",
"postgreSqlPooling": true,
"connectionTimeoutSeconds": 15,
"commandTimeoutSeconds": 30,
"encrypted": true,
"databases": [
{
"databaseName": "appdb",
"users": [
{
"username": "app_owner",
"password": "<encrypted-or-plain>",
"encrypted": true,
"roles": [
"db_owner"
],
"connectionString": "Host=pg01.contoso.local;Database=appdb;Username=app_owner;Password=********;Ssl Mode=Require;Port=5432;Timeout=15;Command Timeout=30;Pooling=true;"
}
]
}
]
}
],
"trash": []
}Config notes:
selectedServerNamestores the selected connection identifier, not just the host nameserverIdentifieris a generated connection id used by selection and TUI workflowsdisplayNameis the human-readable label shown in the UI- Top-level
timeoutsprovide defaults; per-server timeout values override them - New SQL Server connections default to port
1433, admin databasemaster, and trust modefalse - New PostgreSQL connections default to port
5432, admin databasepostgres, SSL modeprefer, and poolingtrue - New MySQL/MariaDB connections default to port
3306, admin databasemysql, SSL moderequired, poolingtrue, andallowPublicKeyRetrievalfalse versionHistorymetadata is generated for servers, databases, users, and trash entries even though it is omitted from the example above for brevity- The TUI trash bin stores deleted server, database, and user config entries so they can be restored later
- Generic roles in config are provider-aware at execution time: SQL Server supports
db_owner,db_datareader, anddb_datawriter; PostgreSQL and MySQL/MariaDB supportdb_owneronly
Current top-level commands:
version-term(terminal UI; also accepted asterm,terminal, ortui)-ui(browser UI; also accepted asui,webui, orweb)view-configinit-configadd-serveredit-serverremove-serverselect-serversync-servertest-connectionset-themeshow-trashrestore-trashshow-databasesshow-database-sizesshow-tablesshow-connectionsrun-querycreate-databaseremove-databasecreate-userset-user-accessadd-roleremove-roleshow-userstest-user-loginremove-userupdate-passwordenable-config-encryptiondisable-config-encryptionmigrate-config-encryption-formathelp
Compatibility notes:
sql-manager --versionis supported in addition tosql-manager version- PowerShell-style compatibility is still supported, for example
--action CreateUser - Many server-scoped commands can reuse the selected connection and stored admin credentials from config when those options are omitted
--jsonprints one result document per command and implies--non-interactive;--quietkeeps text output but prints only failures and data tables- Slash-prefixed switches such as
/jsonare accepted on Windows only, so absolute POSIX paths work as option values on Linux and macOS
Add a SQL Server connection:
.\sql-manager.exe add-server --display-name "Primary SQL" --server-name sql01.contoso.local --provider sqlserver --port 1433 --admin-database master --admin-username sa --admin-password "Secret123!" --trust-mode falseAdd a PostgreSQL connection:
.\sql-manager.exe add-server --display-name "Primary Postgres" --server-name pg01.contoso.local --provider postgresql --port 5432 --admin-database postgres --admin-username postgres --admin-password "Secret123!" --ssl-mode require --pooling trueAdd an Azure MySQL Flexible Server connection:
.\sql-manager.exe add-server --display-name "Azure MySQL" --server-name mysql-flex-prod.mysql.database.azure.com --provider mysql --port 3306 --admin-database mysql --admin-username sqlmanager --admin-password "Secret123!" --mysql-ssl-mode required --pooling true --allow-public-key-retrieval falseAdd a local MariaDB connection:
.\sql-manager.exe add-server --display-name "Local MariaDB" --server-name localhost --provider mariadb --port 3306 --admin-database mysql --admin-username root --admin-password "Secret123!" --mysql-ssl-mode preferred --pooling true --allow-public-key-retrieval trueSelect the active connection by identifier:
.\sql-manager.exe select-server --server-identifier 2Sync the selected server into config:
.\sql-manager.exe sync-server --admin-password "Secret123!"Sync merges rather than rebuilds. Databases and users the server reports are updated in place, keeping their entry ids, version history, and the passwords the config is holding for them. Newly seen ones are added. Anything the server no longer reports is moved to the trash bin rather than dropped, because the config is the only place a user's password is written down; the result says how many entries moved.
Create a database:
.\sql-manager.exe create-database --server-name sql01.contoso.local --admin-username sa --admin-password "Secret123!" --database-name LabDBCreate a SQL Server user with generated password and two roles:
.\sql-manager.exe create-user --server-name sql01.contoso.local --admin-username sa --admin-password "Secret123!" --database-name LabDB --user-name LabDBUser --roles db_owner,db_datareaderRemove a role from a user:
.\sql-manager.exe remove-role --server-name sql01.contoso.local --admin-username sa --admin-password "Secret123!" --database-name LabDB --user-name LabDBUser --roles db_datareaderRemove a connection from the config (the entry moves to the trash bin; nothing is dropped on the server):
.\sql-manager.exe remove-server --server-identifier 2List the trash bin and recover an entry:
.\sql-manager.exe show-trash
.\sql-manager.exe restore-trash --trash-id 85cd9764199845b784b3980479172Check that a stored connection still works before running anything against it:
.\sql-manager.exe test-connection --server-identifier 1 --admin-password "Secret123!"Review database sizes, table row counts, and active sessions:
.\sql-manager.exe show-database-sizes --admin-password "Secret123!"
.\sql-manager.exe show-tables --database-name LabDB --admin-password "Secret123!"
.\sql-manager.exe show-connections --admin-password "Secret123!"Run an ad-hoc statement, inline or from a file:
.\sql-manager.exe run-query --database-name LabDB --query "SELECT TOP 10 * FROM dbo.Orders;" --admin-password "Secret123!"
.\sql-manager.exe run-query --database-name LabDB --query-file .\reports\daily.sql --max-rows 2000 --admin-password "Secret123!"Set a user's access across several databases in one call, revoking one of them:
.\sql-manager.exe set-user-access --user-name LabDBUser --access "LabDB=db_owner,db_datareader" --access "ScratchDB=" --admin-password "Secret123!"Script against the CLI with JSON output:
$databases = .\sql-manager.exe show-databases --admin-password "Secret123!" --json | ConvertFrom-Json
$databases.data.databasessql-manager show-tables --database-name LabDB --admin-password "Secret123!" --json | jq -r '.data.rows[][0]'Test a stored or supplied user login:
.\sql-manager.exe test-user-login --server-name pg01.contoso.local --database-name appdb --user-name app_owner --user-password "Secret123!"Enable config encryption:
.\sql-manager.exe enable-config-encryption --config-path .\sql-config.json --encryption-password "ComplexPass!123"Migrate an older encrypted config to the current full-file encrypted format:
.\sql-manager.exe migrate-config-encryption-format --config-path .\sql-config.json --encryption-password "ComplexPass!123"Disable config encryption:
.\sql-manager.exe disable-config-encryption --config-path .\sql-config.json --encryption-password "ComplexPass!123"- Azure Database for MySQL Flexible Server: use the server FQDN, port
3306, the admin login shown in Azure, and keep TLS on with--mysql-ssl-mode requiredorverifyfull. Leave--allow-public-key-retrieval false. - Local MySQL or MariaDB: use the local host or VM host name, keep
--mysql-ssl-mode preferredwhen the server negotiates TLS, or switch todisabledonly if the server is intentionally running without TLS.--allow-public-key-retrieval truecan be needed for local MySQL 8 password auth without TLS. - SQL Manager manages MySQL/MariaDB accounts as
'username'@'%'so the generated login can connect from Azure clients and local tools unless you later tighten the host part manually. - The MySQL/MariaDB
db_ownermapping isGRANT ALL PRIVILEGES ON db.* TO 'username'@'%';. That is the closest equivalent to the SQL Server-style full-database owner role this tool manages. - To create users and grant that access, the admin account you configure for MySQL/MariaDB needs privileges to run
CREATE USER,ALTER USER,DROP USER, andGRANTon the target databases. Azure Flexible Server admin accounts have the needed rights for this workflow.
Running sql-manager with no arguments asks which front end you want:
How would you like to run sql-manager?
> Run in Terminal
Run in Web UI (opens a browser)
Pick one with the arrow keys and Enter. To skip the question, start a front end directly with sql-manager -term or sql-manager -ui. When input is redirected there is nobody to answer, so the terminal UI starts as it always has.
Current TUI areas include:
- Server management: select active server, add, edit, or remove a connection, sync configuration, and show or manage databases
- User management: create users, manage roles, show users, test user logins, remove users, and update passwords
- Server insights: test the connection, review database sizes, list tables with row counts, inspect active sessions, and run ad-hoc SQL in the query console (Ctrl+R runs the statement)
- Configuration menu: save, view config, initialize config, change theme, toggle password encryption, inspect the trash bin, and refresh from disk
- Help and About views: version details, command reference, repository URL, and general navigation help
sql-manager -ui serves the same operations as the TUI in a browser and opens it for you.
.\sql-manager.exe -uisql-manager -ui --ui-port 9000 --no-browserThe command prints the URL to use:
Web UI listening on http://127.0.0.1:8973/
Open: http://127.0.0.1:8973/?token=<session token>
Options:
--ui-port <port>pins the port. Without it the server starts at 8973 and scans upward for a free one, so a second instance still runs. This is deliberately separate from--port, which is the database port--no-browserprints the URL without launching a browser, for headless or remote-forwarded use--encryption-password <password>unlocks an encrypted config at startup instead of unlocking from the Session panel--config-path <path>picks the config, exactly as it does for every other command
Panels mirror the TUI: Connections (select, add, edit, remove, sync, test, history), Databases (list, sizes, tables, active sessions, create, remove), Users (show, create, set access, roles, test login, remove, update password), Query (Ctrl+Enter runs the statement), Activity (what this page has run), and Config (unlock/lock, save, theme, encryption, trash bin).
A context bar sits under the tabs and holds the choice every panel works against:
- Connection lists the configured connections. Picking one selects it in the config, exactly as the Select button on the Connections tab does
- Database offers the databases of that connection and accepts a name you type. The list comes from the server, falling back to the databases already recorded in the config; Reload databases re-reads it from the server
- The summary beside them, and a line on the Databases, Users, and Query panels, names the connection, provider, host, admin user, and database in play, so a result is never read against the wrong server
Users, List tables, Query, and every database field in an action form inherit that choice, so a database is named once rather than retyped on each panel. A query with no database chosen runs against the connection's admin database, which the Query panel says outright. The chosen database is per connection and lives in the browser tab, not the config.
Switching connections tests it once, quietly, and reports the outcome in the header pill next to the config path. Click that pill to test again. A connection that does not answer is not asked for its database list, so a bad admin password shows up on arrival rather than three clicks later.
The connection form is shaped by the provider you pick, and re-shapes itself when you change it:
- SQL Server offers a TLS mode dropdown —
Encrypt=False, Trust=False,Encrypt=True, Trust=True, orEncrypt=Strict, Trust=False - PostgreSQL offers an SSL mode dropdown — Prefer, Require, Disable, VerifyCA, VerifyFull — plus a pooling toggle
- MySQL / MariaDB offers its own SSL mode list plus pooling and Allow public key retrieval
The choices come from the same normalizers the save validates against, so the form cannot offer a value the save would reject. Every field carries a note saying what it means and what blank does: the default port and admin database for that provider, host examples including a named SQL Server instance, what each TLS level actually enforces, and the timeout the config falls back to. Switching provider keeps whatever you have already typed.
Editing shows what is stored — provider, port, admin database, TLS or SSL mode, pooling, and timeouts all come back as saved. The admin password box stays blank and says so: leaving it blank keeps the stored password, and typing one replaces it. Clearing one on purpose means sending an empty password, which update-server --admin-password "" does.
Config → Backup writes a copy of the config beside it, stamped with the UTC time it was taken:
sql-config.20260830T125233Z.json
The copy is byte for byte, so an encrypted config stays encrypted, and a locked config can still be backed up. Nothing is overwritten: each click writes a new file.
Restoring is manual. Stop sql-manager, copy the backup over sql-config.json under that exact name, and start it again. This is separate from the one-deep sql-config.json.bak that every save leaves behind, which the next save overwrites — a stamped backup is the one that survives. Backups hold the same secrets as the config, so keep them where you would be willing to keep the config itself.
The Scope on the remove-user form decides how far the removal reaches:
- Database removes the user only from the databases listed on the form
- Server drops the login itself, which reaches every database on that connection whatever the list says. The API also accepts
Both, which does the same thing asServer
The form opens on Database, and a Remove button on a user row opens it scoped to that row's database. Choosing Server asks for confirmation first, because the config entries go to the trash bin but the changes on the server cannot be undone. Dropping a database asks in the same way.
Every result table carries the same tools: a filter box, click-to-sort headers (numeric where the column is numeric), Copy CSV, Download CSV, and a line saying how many rows are shown, how long the call took, and whether the row limit truncated it.
Results are also where the work happens:
- List databases gives each row Use (adopt it as the chosen database), Tables, Users, History, and Drop
- Show users gives each row Roles, Password, Test login, History, and Remove, each opening the matching form already filled in for that user and database
History reads the version history the config records for a connection, a database, a user, or a trash entry — the same history the TUI shows, and the only other place it is readable. Version details name what changed; passwords appear as a state word, never a value.
- Ctrl+Enter runs. With text selected, only the selection runs, and the result says so
- Ctrl+Up and Ctrl+Down walk the history, which also sits in a dropdown. The last 25 statements are kept in the browser tab; a statement that was refused is not among them
- Confirm writes and drops (on by default) asks before anything that writes, and says outright when an
UPDATEorDELETEhas noWHEREclause - Read-only refuses anything but a
SELECT-shaped statement, rather than trusting the confirmation - Cancel stops a running statement. The server answers a cancel while the query still holds the request lock, so it does not wait in line behind it
- A spinner in the header names the call in flight, and the button that started it stays out of action until it finishes
- The Activity panel keeps the last 100 calls with their outcome, message, and timing. It lives in the browser tab and is lost when the tab closes
- The header says whether an admin password is currently held in the page
Notes:
- The theme paints both front ends. The Config panel previews a theme as you move through the list and writes it with Save theme. The page derives its panels, borders, and muted text from the palette the terminal UI uses, lifting any colour that would otherwise be unreadable against that background
- Enabling encryption from the Config panel keeps the session unlocked with the password you just typed, so the next write is not refused. Disabling it clears the held password
- While an encrypted config is locked, reads still work but writes are refused; the Config panel shows a banner saying so
- The admin password is typed once in the header and held only in the page for that tab. It is never written to disk or browser storage
- Both
127.0.0.1andlocalhostwork; any other host name is refused, which closes the DNS-rebinding path - Requests are handled one at a time because they all read and write the same config file. They are accepted concurrently so that Cancel can be heard, but only one runs at a time
- The active tab, the chosen database, and the query history live in
sessionStorage, which is per browser tab and cleared when it closes. So does the session token the launch URL carried; the admin password never is - Stop the server with Ctrl+C or the Shut down button
Run validation locally:
.\test.ps1 -Configuration ReleaseTesting notes:
- The test project targets xUnit v3, which runs on Microsoft.Testing.Platform rather than VSTest
global.jsoncarries thetest.runneropt-in that .NET 10 requires for that runner- Under this runner
dotnet testtakes the project via--project <path>, and it rejects--nologo
Build debug output:
.\build.ps1 -Target DebugBuild release output:
.\build.ps1 -Target ReleasePublish self-contained production output for selected runtimes:
.\build.ps1 -Target Prod -Runtime win-x64,linux-x64Publish the full runtime matrix:
.\build.ps1 -Target Prod -Runtime AllCurrent publish targets supported by build.ps1:
win-x64win-arm64osx-x64osx-arm64linux-x64linux-arm64linux-musl-x64linux-musl-arm64
Build outputs are written under:
artifacts\debug
artifacts\release
artifacts\prod\<runtime>
Versioning notes:
build.ps1incrementsbuild-number.txtwhen-VersionOverrideis not supplied-BuildNumberand-VersionOverrideare mutually exclusive-InformationalVersionOverridelets you keep a stable numeric version while appending metadata such as a commit hash
The repository includes .github/workflows/release.yml to publish release assets from Git tags.
The workflow:
- runs on every pushed tag
- requires the tag to be a numeric version like
1.0.0or1.0.0.1 - uses
actions/checkout@v5andactions/setup-dotnet@v5so the release pipeline is aligned with the GitHub Actions Node.js 24 transition - runs
.\test.ps1 -Configuration Release - publishes self-contained binaries for the full runtime matrix
- zips each runtime folder into a release asset
- creates or updates the GitHub release and uploads the generated archives
Release builds use the tag as the numeric version and append the short commit SHA to the informational version. For example, tag 1.0.0 built from commit abcdef1... becomes informational version 1.0.0+abcdef1.
GitHub Actions note:
- GitHub will force JavaScript actions to run on Node.js 24 by default on June 2, 2026, and remove Node.js 20 from hosted runners on September 16, 2026
- this repository already uses the Node.js 24-ready major versions of the GitHub-maintained actions in
release.yml - on self-hosted runners,
actions/setup-dotnet@v5requires a runner version that supports Node.js 24, so keep the runner updated rather than relying on the temporaryFORCE_JAVASCRIPT_ACTIONS_TO_NODE24orACTIONS_ALLOW_USE_UNSECURE_NODE_VERSIONoverrides
- Started without arguments the app asks whether to open the terminal UI or the web UI, and switches to CLI mode when arguments are supplied
- SQL Server role aliases accepted by the CLI include
dbowner,db_owner,dbreader,db_reader,db_datareader,dbwriter,db_writer, anddb_datawriter - PostgreSQL and MySQL/MariaDB currently support
db_owneronly; reader and writer role aliases are rejected for those targets - Existing encrypted configs from older versions remain readable and can be migrated to the full-file encrypted format