@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/dist/app.js +39 -0
  4. package/dist/cli.js +263 -0
  5. package/dist/commands/configgen.js +128 -0
  6. package/dist/commands/explore.js +306 -0
  7. package/dist/commands/terminal-prompt.js +68 -0
  8. package/dist/config/load.js +386 -0
  9. package/dist/config/resolve-environment.js +164 -0
  10. package/dist/config/resolvers/env.js +12 -0
  11. package/dist/config/resolvers/file.js +24 -0
  12. package/dist/config/resolvers/groups.js +1 -0
  13. package/dist/config/resolvers/index.js +49 -0
  14. package/dist/config/resolvers/literal.js +7 -0
  15. package/dist/config/resolvers/prompt.js +159 -0
  16. package/dist/config/resolvers/stored.js +13 -0
  17. package/dist/config/resolvers/types.js +18 -0
  18. package/dist/config/resolvers/vault.js +365 -0
  19. package/dist/config/schema.js +24 -0
  20. package/dist/config/source.js +105 -0
  21. package/dist/db/connect.js +55 -0
  22. package/dist/db/identifiers.js +6 -0
  23. package/dist/db/paths.js +14 -0
  24. package/dist/db/store.js +266 -0
  25. package/dist/db/tester.js +190 -0
  26. package/dist/db/validate.js +99 -0
  27. package/dist/environments.js +137 -0
  28. package/dist/explorer/catalog.js +383 -0
  29. package/dist/explorer/connect-entry.js +31 -0
  30. package/dist/explorer/explain.js +13 -0
  31. package/dist/explorer/format.js +134 -0
  32. package/dist/explorer/rows.js +50 -0
  33. package/dist/explorer/session.js +105 -0
  34. package/dist/prompts.js +52 -0
  35. package/dist/screens/environments.js +226 -0
  36. package/dist/screens/explorer.js +148 -0
  37. package/dist/screens/header.js +8 -0
  38. package/dist/screens/index.js +13 -0
  39. package/dist/screens/new-connection.js +178 -0
  40. package/dist/screens/panes/common.js +35 -0
  41. package/dist/screens/panes/history.js +36 -0
  42. package/dist/screens/panes/info.js +61 -0
  43. package/dist/screens/panes/query.js +113 -0
  44. package/dist/screens/panes/table.js +178 -0
  45. package/dist/screens/panes/tables.js +142 -0
  46. package/dist/screens/prompt-modal.js +18 -0
  47. package/dist/screens/record-view.js +42 -0
  48. package/dist/screens/result-grid.js +126 -0
  49. package/dist/screens/scroll.js +12 -0
  50. package/dist/screens/splash.js +76 -0
  51. package/dist/screens/terminal.js +25 -0
  52. package/dist/screens/types.js +1 -0
  53. package/dist/services.js +1 -0
  54. package/dist/startup.js +84 -0
  55. package/dist/web/server.js +355 -0
  56. package/package.json +67 -5
  57. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  58. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  59. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  60. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  61. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  62. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  63. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  64. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  65. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  66. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  67. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  68. package/web/dist/assets/index-BMLFCtFY.js +17 -0
  69. package/web/dist/assets/index-CMXJRL9O.css +2 -0
  70. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  71. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  72. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  73. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  74. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  75. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  76. 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
- # Temporary Holding Version
1
+ # psql-explorer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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();