@kazurolabs/psql-explorer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +294 -2
- package/dist/app.js +39 -0
- package/dist/cli.js +263 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +306 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +386 -0
- package/dist/config/resolve-environment.js +164 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +365 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +266 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +137 -0
- package/dist/explorer/catalog.js +383 -0
- package/dist/explorer/connect-entry.js +31 -0
- package/dist/explorer/explain.js +13 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/rows.js +50 -0
- package/dist/explorer/session.js +105 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +226 -0
- package/dist/screens/explorer.js +148 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +76 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +355 -0
- package/package.json +67 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-BMLFCtFY.js +17 -0
- package/web/dist/assets/index-CMXJRL9O.css +2 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/index.html +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kazuro Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,295 @@
|
|
|
1
|
-
#
|
|
1
|
+
# psql-explorer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Explore your Postgres databases from the terminal: browse schemas and tables, page through rows,
|
|
4
|
+
look at columns, indexes and constraints, and run SQL, in an interactive TUI or from the command
|
|
5
|
+
line. It is in the spirit of pgweb, without a browser.
|
|
6
|
+
|
|
7
|
+
A team shares one set of environments without a database of its own: the config is a folder of
|
|
8
|
+
small YAML files, kept in a directory or in HashiCorp Vault, and every password is looked up when
|
|
9
|
+
it is needed (from Vault, an environment variable, a file, a prompt, ...). Production environments
|
|
10
|
+
are opened **read only** by default, so nothing typed there can change data.
|
|
11
|
+
|
|
12
|
+
> **Disclaimer:** psql-explorer is under active development and has not reached a stable release.
|
|
13
|
+
> It connects to real databases, including production ones, and runs the SQL you give it. Check
|
|
14
|
+
> which environment you are in, keep production read only, and expect breaking changes between
|
|
15
|
+
> versions. It is provided as is, without warranty (see [LICENSE](LICENSE)).
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Node.js 22.13 or newer
|
|
20
|
+
- PostgreSQL databases
|
|
21
|
+
- An interactive terminal for the TUI. The commands (`tables`, `query`, ...) need none.
|
|
22
|
+
|
|
23
|
+
## Install and run
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx @kazurolabs/psql-explorer # once, without installing
|
|
27
|
+
npm i -g @kazurolabs/psql-explorer # or install the psql-explorer command
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Quick start
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
export PSQL_EXPLORER_ENVIRONMENTS=vault:secret/data/psql-explorer # or a directory
|
|
34
|
+
export VAULT_ADDR=https://vault.example.com:8200
|
|
35
|
+
vault login # or VAULT_TOKEN, see below
|
|
36
|
+
psql-explorer
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pick an environment and press `Enter`. Without a shared config psql-explorer still starts, and `n`
|
|
40
|
+
saves your own connections.
|
|
41
|
+
|
|
42
|
+
## Web UI
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
psql-explorer --web [--port 7878] [--no-open] # serves the UI and API on http://127.0.0.1:7878 and opens it
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The UI lives in `web/` (Vite, React, Tailwind, shadcn/ui) and is powered by the same config,
|
|
49
|
+
Vault lookups and read-only rules as the TUI. A value the config says to `prompt` for (a password, say) is asked for in a dialog in the page, and remembered until the server stops. Picking an environment opens its database: the schemas on the left open to
|
|
50
|
+
their tables, views, functions, sequences and types, and the one you pick is shown on the right
|
|
51
|
+
(columns, indexes, constraints, definition and rows for a table; the source of a function; and so on).
|
|
52
|
+
Every view has an address of its own, e.g. `#/staging/table/app/users`. The server listens on loopback only. From a checkout, `npm run dev:web` starts the API and Vite with hot reload together (run
|
|
53
|
+
`npm run install:web` once first), and `npm run build` compiles the CLI and builds `web/dist`.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
psql-explorer # the interactive TUI
|
|
59
|
+
psql-explorer envs # the environments you can use
|
|
60
|
+
psql-explorer schemas --env staging
|
|
61
|
+
psql-explorer tables --env staging [--schema app]
|
|
62
|
+
psql-explorer describe --env staging users # or app.users
|
|
63
|
+
psql-explorer rows --env staging users --limit 20 --order created_at --desc
|
|
64
|
+
psql-explorer query --env staging "select count(*) from orders"
|
|
65
|
+
echo "select now()" | psql-explorer query --env staging
|
|
66
|
+
psql-explorer info --env staging
|
|
67
|
+
psql-explorer history --env staging
|
|
68
|
+
psql-explorer configgen:file # a commented example environment file
|
|
69
|
+
psql-explorer configgen:vault
|
|
70
|
+
psql-explorer secret:set prod-password # for `source: stored`; asks, or reads stdin
|
|
71
|
+
psql-explorer secret:delete prod-password
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every command prints a table like psql's; `--format csv` or `--format json` (or `--json`) give
|
|
75
|
+
output for other tools. In JSON, numbers that fit a JavaScript number, booleans and `json`/`jsonb`
|
|
76
|
+
values are themselves; `bigint`, `numeric` and everything else are the text Postgres sent, so no
|
|
77
|
+
digit is lost.
|
|
78
|
+
|
|
79
|
+
| Option | Used for |
|
|
80
|
+
| ----------------- | --------------------------------------------------------------------------- |
|
|
81
|
+
| `--config <base>` | the shared config's base path; default `$PSQL_EXPLORER_ENVIRONMENTS` |
|
|
82
|
+
| `--env <name>` | the environment: a shared one, else a local connection of that name |
|
|
83
|
+
| `--schema <name>` | `tables`, `describe`, `rows`: the schema; default the environment's |
|
|
84
|
+
| `--format <f>` | `table` (default), `csv` or `json` |
|
|
85
|
+
| `--limit <n>` | `rows`: rows per page (default 100); `history`: how many (default 50) |
|
|
86
|
+
| `--offset <n>` | `rows`: rows to skip |
|
|
87
|
+
| `--order <cols>` | `rows`: sort by these columns, comma separated; `--desc` for descending |
|
|
88
|
+
| `--max-rows <n>` | `query`: read at most this many rows (default 1000), the rest are not read |
|
|
89
|
+
| `--timeout <s>` | the server cancels a statement after this many seconds (default 30, 0 none) |
|
|
90
|
+
|
|
91
|
+
Exit codes: `0` done, `1` failed (the reason is on stderr), `2` wrong command line. A value the
|
|
92
|
+
config says to `prompt` for is asked for on the terminal (a secret without echo); with no terminal
|
|
93
|
+
the command fails and names the value.
|
|
94
|
+
|
|
95
|
+
`query` runs one statement at a time. Each statement is kept in the history of that environment
|
|
96
|
+
(in your local data, see below), with how long it took and how many rows it returned.
|
|
97
|
+
|
|
98
|
+
## The TUI
|
|
99
|
+
|
|
100
|
+
The **Environments** screen lists the shared environments and your local connections, with their
|
|
101
|
+
tier, address, TLS mode and whether they are read only. `Enter` explores one, `t` tests the
|
|
102
|
+
connection, `n` adds a local connection, `e` and `d` edit and delete one.
|
|
103
|
+
|
|
104
|
+
Exploring an environment has four tabs (`1`–`4`, or `Tab`):
|
|
105
|
+
|
|
106
|
+
1. **Tables** — the tables, views and materialized views of a schema, with row estimates, sizes
|
|
107
|
+
and comments. `/` filters, `s` picks another schema (remembered per environment), `Enter` opens
|
|
108
|
+
a table on its **Rows** (a page at a time, sorted by the primary key), its **Structure**
|
|
109
|
+
(columns, types, defaults) and its **Indexes & constraints**; `Tab` switches between them.
|
|
110
|
+
In the rows, `n`/`p` page, `o` sorts by the selected column (again for descending, a third time
|
|
111
|
+
for the default), `c` counts the rows exactly, and `Enter` shows a row field by field.
|
|
112
|
+
2. **Query** — type a statement and press `Enter`; `↑`/`↓` recall earlier ones. The result is a
|
|
113
|
+
grid like the rows. `Esc` leaves the editor, `e` returns to it, `r` runs it again.
|
|
114
|
+
3. **History** — what was run against this environment, newest first. `Enter` puts a statement
|
|
115
|
+
back in the Query tab.
|
|
116
|
+
4. **Info** — the server version, database size and session settings, and the other connections
|
|
117
|
+
to the database.
|
|
118
|
+
|
|
119
|
+
In every grid the arrow keys (or `h` `j` `k` `l`) move a cell at a time, `PgUp`/`PgDn` a page,
|
|
120
|
+
`g`/`G` to the first and last row, `0`/`$` to the first and last column. `Esc` goes back, `q`
|
|
121
|
+
quits (when not typing), and so does `Ctrl+C`.
|
|
122
|
+
|
|
123
|
+
## Read only, and other safety
|
|
124
|
+
|
|
125
|
+
- An environment with `readOnly: true` opens every session with
|
|
126
|
+
`default_transaction_read_only = on`, so the server refuses `INSERT`, `UPDATE`, `DELETE`, DDL
|
|
127
|
+
and the like. `readOnly` defaults to `protected`, which defaults to true for the `production`
|
|
128
|
+
tier. The header shows **READ ONLY**. This guards against mistakes; it is not a permission
|
|
129
|
+
system, so give people a database role that can only read where it matters (Vault's database
|
|
130
|
+
secrets engine is a good way to hand those out, see below).
|
|
131
|
+
- An environment with `confirm: true` makes you type back two random words (for example
|
|
132
|
+
`maple ticket`) before it is opened, in the TUI, the web UI and on the terminal, so a protected
|
|
133
|
+
environment is never entered by accident, even when its password is stored in the config.
|
|
134
|
+
`confirm` defaults to `protected`; set `confirm: false` to turn it off. It is asked once per run
|
|
135
|
+
of the program, nothing is looked up before it is answered, and it fails (rather than skips)
|
|
136
|
+
when nobody can be asked, e.g. in a script with no terminal. The web UI also shows a banner
|
|
137
|
+
above the SQL box on a protected environment.
|
|
138
|
+
- In the web UI, the SQL box of a protected, read only environment has a switch at its top right.
|
|
139
|
+
Turning it on asks for two random words again (always, whatever `confirm` says), then every
|
|
140
|
+
connection of that environment is opened read write until you switch it off or stop the server.
|
|
141
|
+
The banner turns red and pulses while it is on. Turning it off needs no words.
|
|
142
|
+
- Every statement has a time limit (`statement_timeout`, 30 s unless `--timeout` says otherwise),
|
|
143
|
+
and a query reads at most `--max-rows` rows (1000 in the TUI), so a `SELECT *` on a big table
|
|
144
|
+
does not fill your memory.
|
|
145
|
+
- Values are shown as the text Postgres sends: no time zone conversion, no rounding.
|
|
146
|
+
- Short-lived credentials and certificates issued by Vault are revoked when you leave the
|
|
147
|
+
environment or the command ends.
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
### Environment variables
|
|
152
|
+
|
|
153
|
+
| Variable | Used for |
|
|
154
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
155
|
+
| `PSQL_EXPLORER_ENVIRONMENTS` | the shared config's base path, a directory or `vault:<path>`; `--config` wins |
|
|
156
|
+
| `PSQL_EXPLORER_CONFIG` | the directory holding `explorer.db` (local connections, stored secrets, settings, query history) |
|
|
157
|
+
| `XDG_CONFIG_HOME` | without `PSQL_EXPLORER_CONFIG`, `explorer.db` is in `$XDG_CONFIG_HOME/psql-explorer` |
|
|
158
|
+
| | (else `~/.config/psql-explorer`) |
|
|
159
|
+
| `VAULT_ADDR` | Vault's address (required for anything in Vault) |
|
|
160
|
+
| `VAULT_TOKEN` | the Vault token to use |
|
|
161
|
+
| `VAULT_ROLE_ID`, `VAULT_SECRET_ID` | log in to Vault through AppRole |
|
|
162
|
+
| `VAULT_JWT`, `VAULT_ROLE` | log in to Vault with a JWT / OIDC token |
|
|
163
|
+
| `VAULT_NAMESPACE` | the Vault Enterprise namespace |
|
|
164
|
+
| any name | read by an environment's `source: env` values, for example `STAGING_DB_PASSWORD` |
|
|
165
|
+
|
|
166
|
+
Empty values count as unset. `explorer.db` is created readable by its owner only.
|
|
167
|
+
|
|
168
|
+
### Shared configuration
|
|
169
|
+
|
|
170
|
+
Environments shared by a team live under a **base path**, given with `--config <base>` or
|
|
171
|
+
`$PSQL_EXPLORER_ENVIRONMENTS`. It holds one folder per environment, each with an `explorer.yaml`:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
<base>/staging/explorer.yaml one environment; the folder name is its name
|
|
175
|
+
<base>/production/explorer.yaml
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The base path is a directory (a git checkout, a shared drive, ...) or Vault:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
psql-explorer --config ~/team/explorer-config
|
|
182
|
+
psql-explorer --config vault:secret/data/psql-explorer # a KV v2 "<mount>/data/<path>"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Environments are found by listing the base path. A folder without an `explorer.yaml` is ignored.
|
|
186
|
+
An environment file that is wrong is reported (on the Environments screen, or on stderr) and left
|
|
187
|
+
out; the others still load.
|
|
188
|
+
|
|
189
|
+
#### Config in Vault
|
|
190
|
+
|
|
191
|
+
In Vault each environment is the `explorer.yaml` key of a secret below the base path:
|
|
192
|
+
`secret/data/psql-explorer/staging` is the `staging` environment. psql-explorer lists
|
|
193
|
+
`secret/metadata/psql-explorer` to find them, so **what a person sees follows their Vault policy**:
|
|
194
|
+
an environment they may not read is simply not in their list.
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
vault kv put secret/psql-explorer/staging explorer.yaml=@staging.yaml
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```hcl
|
|
201
|
+
path "secret/metadata/psql-explorer" { capabilities = ["list"] }
|
|
202
|
+
path "secret/data/psql-explorer/staging" { capabilities = ["read"] } # one per environment
|
|
203
|
+
# or: path "secret/data/psql-explorer/*" { capabilities = ["read"] }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### An environment file
|
|
207
|
+
|
|
208
|
+
`psql-explorer configgen:file` (or `configgen:vault`) prints a fully commented example. In short:
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
tier: production # production | reference | demo | development | local
|
|
212
|
+
protected: true # optional; default: tier is production
|
|
213
|
+
readOnly: true # optional; default: protected
|
|
214
|
+
confirm: true # optional; default: protected (type two random words to open it)
|
|
215
|
+
host: db.prod.internal
|
|
216
|
+
port: 5432
|
|
217
|
+
database: app
|
|
218
|
+
schema: app # optional; where browsing starts, and first on the search_path; default public
|
|
219
|
+
username: readonly
|
|
220
|
+
password: # or `auth: none` for a server that takes no password
|
|
221
|
+
source: vault
|
|
222
|
+
value: secret/data/db/prod#password
|
|
223
|
+
tls:
|
|
224
|
+
mode: verify-full # disable | require | verify-ca | verify-full
|
|
225
|
+
ca: {source: file, value: ./ca.pem}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Any of `host`, `port`, `database`, `schema`, `username`, `password` and the TLS `ca`, `cert`,
|
|
229
|
+
`key` and `keyPassphrase` can be written as plain text or as a `source` and a `value`:
|
|
230
|
+
|
|
231
|
+
| `source` | the value is |
|
|
232
|
+
| --------------------- | ---------------------------------------------------------------------------------- |
|
|
233
|
+
| `plaintext`, `inline` | the text in `value` |
|
|
234
|
+
| `env` | the environment variable named in `value` |
|
|
235
|
+
| `file` | the content of the file in `value`, relative to the environment's folder |
|
|
236
|
+
| `stored` | a secret of that name in your local store (`psql-explorer secret:set <name>`) |
|
|
237
|
+
| `prompt` | asked for when connecting (no `value`), and remembered until the program ends |
|
|
238
|
+
| `vault` | a key of a KV v2 secret: `value: <data path>#<key>`, e.g. `secret/data/db/prod#pw` |
|
|
239
|
+
|
|
240
|
+
Vault can also issue the login and the client certificate:
|
|
241
|
+
|
|
242
|
+
```yaml
|
|
243
|
+
credentials: # instead of username and password
|
|
244
|
+
source: vault-database
|
|
245
|
+
value: database/creds/readonly
|
|
246
|
+
tls:
|
|
247
|
+
mode: verify-full
|
|
248
|
+
client: # instead of cert and key (and ca, unless given)
|
|
249
|
+
source: vault-pki
|
|
250
|
+
value: pki/issue/psql-explorer
|
|
251
|
+
commonName: psql-explorer
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Vault
|
|
255
|
+
|
|
256
|
+
Vault needs `VAULT_ADDR`, and a token from the first of: `VAULT_TOKEN`; an AppRole login with
|
|
257
|
+
`VAULT_ROLE_ID` and `VAULT_SECRET_ID`; a JWT login with `VAULT_JWT` and `VAULT_ROLE`; the
|
|
258
|
+
`~/.vault-token` the `vault` CLI writes on `vault login`. Secrets are read each time they are
|
|
259
|
+
needed and never kept, so a rotated password is picked up at once. An expired token is reported
|
|
260
|
+
as such, with a reminder to `vault login`.
|
|
261
|
+
|
|
262
|
+
### Local connections
|
|
263
|
+
|
|
264
|
+
Press `n` on the Environments screen to save a connection of your own (host, port, database,
|
|
265
|
+
username, password, schema and SSL). They live in `explorer.db` with the password unencrypted,
|
|
266
|
+
in a file only you can read, are never read only, and can be used by name with `--env`.
|
|
267
|
+
|
|
268
|
+
## Development
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
npm install
|
|
272
|
+
npm run build # compile src/ to dist/ with tsc
|
|
273
|
+
node dist/cli.js # run the built CLI
|
|
274
|
+
npm test # run the tests (Vitest; builds dist/ first)
|
|
275
|
+
npm run lint # typecheck + ESLint + Prettier check
|
|
276
|
+
npm run format # apply Prettier formatting
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`npm run dev` starts psql-explorer against a demo: a `demo_shop` database (customers, products,
|
|
280
|
+
orders, a 200,000-row `page_views` table with no index on `customer_id` for trying Explain, a view,
|
|
281
|
+
a materialized view and an `audit` schema) on the `psql-explorer-pg` container
|
|
282
|
+
(port 55440, started if it is not running; set `DEMO_PG_URL` to use another server), and a shared
|
|
283
|
+
config in `.demo/config` with a read-write, a read-only and a protected environment. Its data
|
|
284
|
+
directory is `.demo/data`, so your real `~/.config/psql-explorer` is untouched.
|
|
285
|
+
`npm run dev:web` starts the web UI against the same demo. `npm run dev:reset` and
|
|
286
|
+
`npm run dev:web:reset` build the demo again. `npm run dev:plain` runs the TUI without it.
|
|
287
|
+
|
|
288
|
+
The tests that need a real Postgres run when `PSQL_EXPLORER_TEST_PG_URL` is set, and are skipped
|
|
289
|
+
otherwise:
|
|
290
|
+
|
|
291
|
+
```sh
|
|
292
|
+
PSQL_EXPLORER_TEST_PG_URL=postgres://postgres:postgres@localhost:55440/app npm test
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Each run creates a schema of its own (`explorer_test_<random>`) and drops it afterwards.
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { Box, useApp, useInput, useStdin } from 'ink';
|
|
4
|
+
import { screens as defaultScreens, } from './screens/index.js';
|
|
5
|
+
import PromptModal from './screens/prompt-modal.js';
|
|
6
|
+
import { useTerminalSize } from './screens/terminal.js';
|
|
7
|
+
const noSubscribe = () => () => undefined;
|
|
8
|
+
const noPrompt = () => undefined;
|
|
9
|
+
export default function App({ version, services, initialScreen = 'splash', initialParams, fullScreen = false, screens = defaultScreens, }) {
|
|
10
|
+
const [route, setRoute] = useState({
|
|
11
|
+
name: initialScreen,
|
|
12
|
+
params: initialParams,
|
|
13
|
+
key: 0,
|
|
14
|
+
});
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const { columns, rows } = useTerminalSize();
|
|
17
|
+
const { isRawModeSupported } = useStdin();
|
|
18
|
+
const navigate = useCallback((name, ...[params]) => {
|
|
19
|
+
setRoute((previous) => ({ name, params, key: previous.key + 1 }));
|
|
20
|
+
}, []);
|
|
21
|
+
const entry = screens[route.name];
|
|
22
|
+
const { prompts } = services;
|
|
23
|
+
const prompt = useSyncExternalStore(prompts ? prompts.subscribe : noSubscribe, prompts ? prompts.current : noPrompt);
|
|
24
|
+
const screenInput = isRawModeSupported && !prompt;
|
|
25
|
+
// Global quit key, off on screens that take text input; Ctrl+C is handled by Ink (exitOnCtrlC).
|
|
26
|
+
useInput((input) => {
|
|
27
|
+
if (input === 'q')
|
|
28
|
+
exit();
|
|
29
|
+
}, { isActive: screenInput && !entry.capturesInput });
|
|
30
|
+
// The registry ties each component to its own params type; the route carries the params that
|
|
31
|
+
// navigate() type-checked for this screen, so widening here is safe.
|
|
32
|
+
const Screen = entry.component;
|
|
33
|
+
const screen = (_jsx(Screen, { version: version, navigate: navigate, params: route.params, services: services, isInputActive: screenInput }, route.key));
|
|
34
|
+
const modal = prompt && (_jsx(PromptModal, { prompt: prompt, isInputActive: isRawModeSupported }, `${prompt.envName}.${prompt.field}`));
|
|
35
|
+
if (!fullScreen) {
|
|
36
|
+
return (_jsxs(_Fragment, { children: [screen, modal] }));
|
|
37
|
+
}
|
|
38
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", justifyContent: prompt ? 'center' : 'flex-start', children: [_jsx(Box, { display: prompt ? 'none' : 'flex', flexDirection: "column", flexGrow: 1, children: screen }), modal] }));
|
|
39
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import meow from 'meow';
|
|
6
|
+
import App from './app.js';
|
|
7
|
+
import { createVaultClient, defaultRegistry, PromptRequiredError, } from './config/resolvers/index.js';
|
|
8
|
+
import { loadConfig } from './config/load.js';
|
|
9
|
+
import { dataDir } from './db/paths.js';
|
|
10
|
+
import { openStore } from './db/store.js';
|
|
11
|
+
import { testConnection } from './db/tester.js';
|
|
12
|
+
import { configgen } from './commands/configgen.js';
|
|
13
|
+
import { commandNames, isCommand, runCommand } from './commands/explore.js';
|
|
14
|
+
import { createTerminalPrompter } from './commands/terminal-prompt.js';
|
|
15
|
+
import { openSession } from './explorer/session.js';
|
|
16
|
+
import { createPromptQueue } from './prompts.js';
|
|
17
|
+
import { createStartup } from './startup.js';
|
|
18
|
+
import { createWebServer, DEFAULT_WEB_PORT, listen, openBrowser } from './web/server.js';
|
|
19
|
+
// meow reads package.json via importMeta, so the version and description have
|
|
20
|
+
// a single source of truth. It handles --help and --version (exit 0) itself.
|
|
21
|
+
const cli = meow(`
|
|
22
|
+
Usage
|
|
23
|
+
$ psql-explorer start the interactive TUI
|
|
24
|
+
$ psql-explorer envs
|
|
25
|
+
$ psql-explorer schemas --env <name>
|
|
26
|
+
$ psql-explorer tables --env <name> [--schema <schema>]
|
|
27
|
+
$ psql-explorer describe --env <name> <table>
|
|
28
|
+
$ psql-explorer rows --env <name> <table> [--limit 100] [--offset 0] [--order <col>] [--desc]
|
|
29
|
+
$ psql-explorer query --env <name> "<sql>" (or "-", or pipe the SQL in)
|
|
30
|
+
$ psql-explorer info --env <name>
|
|
31
|
+
$ psql-explorer history --env <name>
|
|
32
|
+
$ psql-explorer --web [--port 7878] [--no-open] serve the web UI and open it
|
|
33
|
+
$ psql-explorer web same as --web
|
|
34
|
+
$ psql-explorer configgen:file
|
|
35
|
+
$ psql-explorer configgen:vault
|
|
36
|
+
$ psql-explorer secret:set <name> (asks for it, or pipe it in)
|
|
37
|
+
$ psql-explorer secret:delete <name>
|
|
38
|
+
|
|
39
|
+
Commands
|
|
40
|
+
envs List the environments: the shared ones and your local connections.
|
|
41
|
+
schemas List the schemas of the database.
|
|
42
|
+
tables List the tables and views of a schema, with row estimates and sizes.
|
|
43
|
+
describe Show a table's columns, indexes and constraints.
|
|
44
|
+
rows Print a page of a table's rows.
|
|
45
|
+
query Run one SQL statement and print what it returns. On a read-only
|
|
46
|
+
environment anything that would change data is refused by the server.
|
|
47
|
+
info Show the server version, database size and session settings.
|
|
48
|
+
history The statements run with query or the TUI, newest first.
|
|
49
|
+
web Serve the web UI and its API on http://127.0.0.1:<port> until stopped.
|
|
50
|
+
configgen:file Print a fully commented example environment kept in a directory.
|
|
51
|
+
configgen:vault Print the same for one kept in Vault.
|
|
52
|
+
secret:set Keep a secret in the local store, for config values written as
|
|
53
|
+
source: stored. secret:delete removes it.
|
|
54
|
+
|
|
55
|
+
Options
|
|
56
|
+
--config <base> Where the shared config lives (default: $PSQL_EXPLORER_ENVIRONMENTS): a
|
|
57
|
+
directory, or vault:<KV v2 path> such as vault:secret/data/psql-explorer.
|
|
58
|
+
It holds one <environment>/explorer.yaml per environment.
|
|
59
|
+
--env <name> The environment: a shared one, or a local connection by name
|
|
60
|
+
--schema <name> tables, describe, rows: the schema (default: the environment's)
|
|
61
|
+
--format <f> table (default), csv or json
|
|
62
|
+
--json Same as --format json
|
|
63
|
+
--limit <n> rows: rows per page (default 100); history: how many (default 50)
|
|
64
|
+
--offset <n> rows: rows to skip
|
|
65
|
+
--order <cols> rows: sort by these columns, comma separated
|
|
66
|
+
--desc rows: sort descending
|
|
67
|
+
--max-rows <n> query: read at most this many rows (default 1000)
|
|
68
|
+
--timeout <s> Cancel a statement after this many seconds (default 30, 0 for none)
|
|
69
|
+
--web Start the web UI instead of the TUI (same as the web command)
|
|
70
|
+
--no-open web: do not open the browser
|
|
71
|
+
--port <n> web: the port to listen on (default 7878; the next free one is used if taken)
|
|
72
|
+
--help Show this help
|
|
73
|
+
--version Show the version
|
|
74
|
+
|
|
75
|
+
Run without a command to start the interactive TUI.
|
|
76
|
+
`, {
|
|
77
|
+
importMeta: import.meta,
|
|
78
|
+
flags: {
|
|
79
|
+
config: { type: 'string' },
|
|
80
|
+
env: { type: 'string' },
|
|
81
|
+
schema: { type: 'string' },
|
|
82
|
+
format: { type: 'string' },
|
|
83
|
+
json: { type: 'boolean' },
|
|
84
|
+
limit: { type: 'number' },
|
|
85
|
+
offset: { type: 'number' },
|
|
86
|
+
order: { type: 'string' },
|
|
87
|
+
desc: { type: 'boolean' },
|
|
88
|
+
maxRows: { type: 'number' },
|
|
89
|
+
timeout: { type: 'number' },
|
|
90
|
+
port: { type: 'number' },
|
|
91
|
+
web: { type: 'boolean' },
|
|
92
|
+
open: { type: 'boolean', default: true },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
// Prints an example config and needs neither the local database nor a config.
|
|
96
|
+
const [command, ...args] = cli.input;
|
|
97
|
+
if (command === 'configgen:file' || command === 'configgen:vault') {
|
|
98
|
+
process.stdout.write(configgen(command === 'configgen:file' ? 'file' : 'vault'));
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
const vault = createVaultClient();
|
|
102
|
+
// Opened before the TTY check so a broken data dir is reported the same way everywhere.
|
|
103
|
+
let store;
|
|
104
|
+
try {
|
|
105
|
+
store = openStore(dataDir());
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
process.stderr.write(`psql-explorer: ${error instanceof Error ? error.message : String(error)}\n` +
|
|
109
|
+
'Check the directory permissions, or set PSQL_EXPLORER_CONFIG to another directory.\n');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const readStdin = async () => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
for await (const chunk of process.stdin)
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
117
|
+
};
|
|
118
|
+
// Secrets for `source: stored` values: they need the store but no config.
|
|
119
|
+
if (command === 'secret:set' || command === 'secret:delete') {
|
|
120
|
+
const [name] = args;
|
|
121
|
+
let code = 0;
|
|
122
|
+
if (!name) {
|
|
123
|
+
process.stderr.write(`psql-explorer: ${command} needs the secret's name\n`);
|
|
124
|
+
code = 2;
|
|
125
|
+
}
|
|
126
|
+
else if (command === 'secret:delete') {
|
|
127
|
+
if (!store.deleteSecret(name)) {
|
|
128
|
+
process.stderr.write(`psql-explorer: there is no stored secret named "${name}"\n`);
|
|
129
|
+
code = 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
try {
|
|
134
|
+
const value = process.stdin.isTTY
|
|
135
|
+
? await createTerminalPrompter().ask({ envName: 'secret', field: name, masked: true })
|
|
136
|
+
: // A trailing newline from echo or a file is not part of the secret.
|
|
137
|
+
(await readStdin()).replace(/\r?\n$/, '');
|
|
138
|
+
if (value) {
|
|
139
|
+
store.setSecret(name, value);
|
|
140
|
+
process.stderr.write(`Stored secret ${name}\n`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
process.stderr.write('psql-explorer: the secret is empty; nothing was stored\n');
|
|
144
|
+
code = 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
process.stderr.write('psql-explorer: cancelled; nothing was stored\n');
|
|
149
|
+
code = 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
store.close();
|
|
153
|
+
process.exit(code);
|
|
154
|
+
}
|
|
155
|
+
if (command === 'web' || (command === undefined && cli.flags.web)) {
|
|
156
|
+
const { config, error: configError } = await loadConfig({ base: cli.flags.config, vault });
|
|
157
|
+
const prompts = createPromptQueue();
|
|
158
|
+
const services = {
|
|
159
|
+
store,
|
|
160
|
+
tester: testConnection,
|
|
161
|
+
config,
|
|
162
|
+
configError,
|
|
163
|
+
// Questions are answered in the browser: the page polls /api/prompt and posts the answer.
|
|
164
|
+
resolvers: defaultRegistry(undefined, prompts, store, vault),
|
|
165
|
+
prompts,
|
|
166
|
+
groups: vault.groups,
|
|
167
|
+
openSession,
|
|
168
|
+
};
|
|
169
|
+
const server = createWebServer({
|
|
170
|
+
services,
|
|
171
|
+
staticDir: join(import.meta.dirname, '..', 'web', 'dist'),
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
const requested = cli.flags.port ?? DEFAULT_WEB_PORT;
|
|
175
|
+
const address = await listen(server, requested);
|
|
176
|
+
const port = Number(new URL(address).port);
|
|
177
|
+
if (port !== requested) {
|
|
178
|
+
process.stderr.write(`psql-explorer: port ${requested} is in use, using port ${port} instead\n`);
|
|
179
|
+
}
|
|
180
|
+
process.stderr.write(`psql-explorer web UI on ${address} (Ctrl-C to stop)\n`);
|
|
181
|
+
if (cli.flags.open)
|
|
182
|
+
openBrowser(address);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
store.close();
|
|
186
|
+
process.stderr.write(`psql-explorer: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
process.once('SIGINT', () => {
|
|
190
|
+
server.close();
|
|
191
|
+
void vault.groups.releaseAll().finally(() => {
|
|
192
|
+
store.close();
|
|
193
|
+
process.exit(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
await new Promise(() => { });
|
|
197
|
+
}
|
|
198
|
+
if (command !== undefined) {
|
|
199
|
+
let code = 2;
|
|
200
|
+
if (!isCommand(command)) {
|
|
201
|
+
process.stderr.write(`psql-explorer: unknown command "${command}"; use one of: ${commandNames.join(', ')}\n`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const { config, error: configError } = await loadConfig({ base: cli.flags.config, vault });
|
|
205
|
+
// A person at a terminal can be asked for what the config says to prompt for.
|
|
206
|
+
const prompter = process.stdin.isTTY
|
|
207
|
+
? createTerminalPrompter()
|
|
208
|
+
: { ask: ({ envName, field }) => Promise.reject(new PromptRequiredError(envName, field)) };
|
|
209
|
+
const services = {
|
|
210
|
+
store,
|
|
211
|
+
tester: testConnection,
|
|
212
|
+
config,
|
|
213
|
+
configError,
|
|
214
|
+
resolvers: defaultRegistry(undefined, prompter, store, vault),
|
|
215
|
+
groups: vault.groups,
|
|
216
|
+
openSession,
|
|
217
|
+
};
|
|
218
|
+
code = await runCommand({
|
|
219
|
+
services,
|
|
220
|
+
command,
|
|
221
|
+
args,
|
|
222
|
+
flags: cli.flags,
|
|
223
|
+
out: (text) => process.stdout.write(text),
|
|
224
|
+
err: (text) => process.stderr.write(text),
|
|
225
|
+
readStdin,
|
|
226
|
+
stdinIsTTY: process.stdin.isTTY === true,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Credentials and certificates issued for this command end with it.
|
|
230
|
+
await vault.groups.releaseAll();
|
|
231
|
+
store.close();
|
|
232
|
+
// Let piped output drain before exiting.
|
|
233
|
+
await new Promise((resolve) => process.stdout.write('', resolve));
|
|
234
|
+
process.exit(code);
|
|
235
|
+
}
|
|
236
|
+
// Ink needs raw-mode keyboard input; without a TTY it would crash, so fail clearly.
|
|
237
|
+
if (!process.stdin.isTTY) {
|
|
238
|
+
store.close();
|
|
239
|
+
process.stderr.write('psql-explorer: the TUI needs a terminal (stdin is not a TTY).\n' +
|
|
240
|
+
'Run it directly in a terminal, or use a command such as query (see --help).\n');
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const prompts = createPromptQueue();
|
|
244
|
+
// The screen opens at once; reading the config (which may be in Vault) happens behind the splash
|
|
245
|
+
// screen, which shows each step. It fills in `config` and `configError`. What is wrong with the
|
|
246
|
+
// config is shown on the Environments screen (local connections still work, and so do the
|
|
247
|
+
// environments that are fine).
|
|
248
|
+
const services = {
|
|
249
|
+
store,
|
|
250
|
+
tester: testConnection,
|
|
251
|
+
resolvers: defaultRegistry(undefined, prompts, store, vault),
|
|
252
|
+
groups: vault.groups,
|
|
253
|
+
prompts,
|
|
254
|
+
openSession,
|
|
255
|
+
};
|
|
256
|
+
services.startup = createStartup({ services, base: cli.flags.config, vault });
|
|
257
|
+
const app = render(_jsx(App, { version: cli.pkg.version ?? '0.0.0', fullScreen: true, services: services }),
|
|
258
|
+
// The app owns the whole terminal, and the shell's screen comes back when it exits.
|
|
259
|
+
{ alternateScreen: true });
|
|
260
|
+
await app.waitUntilExit();
|
|
261
|
+
// Short-lived credentials and certificates issued for this session end with it.
|
|
262
|
+
await vault.groups.releaseAll();
|
|
263
|
+
store.close();
|