@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// `psql-explorer configgen:file` and `psql-explorer configgen:vault` print a fully commented
|
|
2
|
+
// example environment file, ready to copy. Every key the loader accepts appears, optional ones
|
|
3
|
+
// commented out.
|
|
4
|
+
const sources = `A setting is plain text (\`host: db.prod.internal\`) or a source and a value:
|
|
5
|
+
source: plaintext | inline | env | file | stored | vault | prompt`;
|
|
6
|
+
const access = `# production | reference | demo | development | local. Required.
|
|
7
|
+
tier: production
|
|
8
|
+
|
|
9
|
+
# Shown with a shield and in red. Optional, default: true for the production tier.
|
|
10
|
+
protected: true
|
|
11
|
+
|
|
12
|
+
# Open every session read only (default_transaction_read_only), so nothing run here can change
|
|
13
|
+
# data. Optional, default: the value of protected.
|
|
14
|
+
readOnly: true
|
|
15
|
+
`;
|
|
16
|
+
const fileEnvironment = `# One environment: <environment>/explorer.yaml below the base path.
|
|
17
|
+
# Use with: psql-explorer --config <base path> (or export PSQL_EXPLORER_ENVIRONMENTS=<base path>)
|
|
18
|
+
# The environment is named after its folder: lower case letters, digits, - and _.
|
|
19
|
+
# ${sources.replaceAll('\n', '\n# ')}
|
|
20
|
+
# Unknown keys are an error.
|
|
21
|
+
|
|
22
|
+
${access}
|
|
23
|
+
# Where the database is. Required. Any of these can use a source instead of plain text.
|
|
24
|
+
host: db.prod.internal
|
|
25
|
+
port: 5432
|
|
26
|
+
database: app
|
|
27
|
+
|
|
28
|
+
# The schema browsing starts in. Optional, default: public
|
|
29
|
+
# schema: app
|
|
30
|
+
|
|
31
|
+
# Database login. username is required unless the login comes from Vault (see the vault example).
|
|
32
|
+
username: readonly
|
|
33
|
+
|
|
34
|
+
# password (default) needs a password; none means the database takes no password.
|
|
35
|
+
auth: password
|
|
36
|
+
|
|
37
|
+
# Required when auth is password. Left out when auth is none.
|
|
38
|
+
password:
|
|
39
|
+
source: file
|
|
40
|
+
value: ./password.txt # relative to this file's folder; the whole content, trimmed
|
|
41
|
+
# Other ways to supply it:
|
|
42
|
+
# password: s3cret # plain text, in the file itself
|
|
43
|
+
# password: {source: env, value: DB_PASSWORD} # an environment variable
|
|
44
|
+
# password: {source: stored, value: prod-password} # a secret kept in the local store
|
|
45
|
+
# password: {source: prompt} # asked for when connecting
|
|
46
|
+
# password: {source: vault, value: "secret/data/db/prod#password"} # a key of a Vault KV v2 secret
|
|
47
|
+
|
|
48
|
+
# TLS. Optional, default: mode disable
|
|
49
|
+
tls:
|
|
50
|
+
# disable | require | verify-ca | verify-full
|
|
51
|
+
mode: verify-full
|
|
52
|
+
# CA certificate (PEM text). Required for verify-ca and verify-full.
|
|
53
|
+
ca:
|
|
54
|
+
source: file
|
|
55
|
+
value: ./certs/ca.pem
|
|
56
|
+
# Client certificate and its private key, for mutual TLS. Give both or neither.
|
|
57
|
+
# cert:
|
|
58
|
+
# source: file
|
|
59
|
+
# value: ./certs/client.pem
|
|
60
|
+
# key:
|
|
61
|
+
# source: file
|
|
62
|
+
# value: ./certs/client-key.pem
|
|
63
|
+
# Only when the key is encrypted.
|
|
64
|
+
# keyPassphrase:
|
|
65
|
+
# source: file
|
|
66
|
+
# value: ./certs/client-key.pass
|
|
67
|
+
`;
|
|
68
|
+
const vaultEnvironment = `# One environment, stored as the explorer.yaml key of a secret below the base path:
|
|
69
|
+
# vault kv put secret/psql-explorer/production explorer.yaml=@production.yaml
|
|
70
|
+
# Use with: psql-explorer --config vault:secret/data/psql-explorer
|
|
71
|
+
# The environment is named after the last part of the secret's path: lower case letters,
|
|
72
|
+
# digits, - and _. Who can read the secret decides who sees the environment.
|
|
73
|
+
# ${sources.replaceAll('\n', '\n# ')}
|
|
74
|
+
# \`file\` values are relative to the working directory here, not to a folder.
|
|
75
|
+
# Unknown keys are an error.
|
|
76
|
+
|
|
77
|
+
${access}
|
|
78
|
+
# Where the database is. Required. Any of these can use a source instead of plain text.
|
|
79
|
+
host:
|
|
80
|
+
source: vault
|
|
81
|
+
value: secret/data/db/prod#host # "<KV v2 data path>#<key>"
|
|
82
|
+
port: 5432
|
|
83
|
+
database: app
|
|
84
|
+
|
|
85
|
+
# The schema browsing starts in. Optional, default: public
|
|
86
|
+
# schema: app
|
|
87
|
+
|
|
88
|
+
# Short-lived login issued by Vault's database secrets engine on every connect, and revoked when
|
|
89
|
+
# you leave the environment. It fills username and password: set neither, and auth must stay
|
|
90
|
+
# password.
|
|
91
|
+
credentials:
|
|
92
|
+
source: vault-database
|
|
93
|
+
value: database/creds/readonly
|
|
94
|
+
# Or a fixed login instead of credentials:
|
|
95
|
+
# username: readonly
|
|
96
|
+
# auth: password # password (default) | none
|
|
97
|
+
# password: # required when auth is password
|
|
98
|
+
# source: vault
|
|
99
|
+
# value: secret/data/db/prod#password
|
|
100
|
+
|
|
101
|
+
# TLS. Optional, default: mode disable
|
|
102
|
+
tls:
|
|
103
|
+
# disable | require | verify-ca | verify-full
|
|
104
|
+
mode: verify-full
|
|
105
|
+
# Client certificate issued by Vault's PKI engine. It fills cert and key (set neither),
|
|
106
|
+
# and ca too unless you give one. Has no effect with mode disable.
|
|
107
|
+
client:
|
|
108
|
+
source: vault-pki
|
|
109
|
+
value: pki/issue/psql-explorer
|
|
110
|
+
commonName: psql-explorer
|
|
111
|
+
# CA certificate (PEM text). Required for verify-ca and verify-full unless client is given.
|
|
112
|
+
# ca:
|
|
113
|
+
# source: vault
|
|
114
|
+
# value: secret/data/db/prod#ca
|
|
115
|
+
# A fixed client certificate and key instead of client. Give both or neither.
|
|
116
|
+
# cert:
|
|
117
|
+
# source: vault
|
|
118
|
+
# value: secret/data/db/prod#cert
|
|
119
|
+
# key:
|
|
120
|
+
# source: vault
|
|
121
|
+
# value: secret/data/db/prod#key
|
|
122
|
+
# keyPassphrase: # only when the key is encrypted
|
|
123
|
+
# source: vault
|
|
124
|
+
# value: secret/data/db/prod#key-passphrase
|
|
125
|
+
`;
|
|
126
|
+
export function configgen(kind) {
|
|
127
|
+
return kind === 'file' ? fileEnvironment : vaultEnvironment;
|
|
128
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// The commands that work without the TUI: list environments, look at what is in a database, page
|
|
2
|
+
// through a table and run SQL. Each prints its result on stdout (as a table, CSV or JSON) and
|
|
3
|
+
// returns the exit code; what went wrong goes to stderr.
|
|
4
|
+
import { addressOf, findEnvironment, listEnvironments, } from '../environments.js';
|
|
5
|
+
import { describeTable, listSchemas, listTables, parseTableRef, serverInfo, tableLabel, } from '../explorer/catalog.js';
|
|
6
|
+
import { commandSummary, formatBytes, formatDuration, formatEstimate, formatResult, outputFormats, } from '../explorer/format.js';
|
|
7
|
+
import { browseRows, DEFAULT_PAGE_SIZE } from '../explorer/rows.js';
|
|
8
|
+
import { DEFAULT_MAX_ROWS, describeQueryError, } from '../explorer/session.js';
|
|
9
|
+
import { connectEntry } from '../explorer/connect-entry.js';
|
|
10
|
+
export const commandNames = [
|
|
11
|
+
'envs',
|
|
12
|
+
'schemas',
|
|
13
|
+
'tables',
|
|
14
|
+
'describe',
|
|
15
|
+
'rows',
|
|
16
|
+
'query',
|
|
17
|
+
'info',
|
|
18
|
+
'history',
|
|
19
|
+
];
|
|
20
|
+
export const isCommand = (name) => commandNames.includes(name);
|
|
21
|
+
/** Thrown for a wrong command line: exit code 2. */
|
|
22
|
+
class UsageError extends Error {
|
|
23
|
+
}
|
|
24
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
25
|
+
// A listing of the explorer's own (not a query's), where nothing is shown for a missing value.
|
|
26
|
+
const result = (columns, rows) => ({
|
|
27
|
+
command: 'SELECT',
|
|
28
|
+
columns: columns.map((name) => ({ name, type: 'text' })),
|
|
29
|
+
rows: rows.map((row) => row.map((value) => value ?? '')),
|
|
30
|
+
rowCount: rows.length,
|
|
31
|
+
truncated: false,
|
|
32
|
+
durationMs: 0,
|
|
33
|
+
});
|
|
34
|
+
const text = (value) => value === null || value === undefined ? null : String(value);
|
|
35
|
+
function formatOf(flags) {
|
|
36
|
+
if (flags.json)
|
|
37
|
+
return 'json';
|
|
38
|
+
const format = flags.format ?? 'table';
|
|
39
|
+
if (!outputFormats.includes(format)) {
|
|
40
|
+
throw new UsageError(`--format must be one of: ${outputFormats.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
return format;
|
|
43
|
+
}
|
|
44
|
+
function environmentFor(options) {
|
|
45
|
+
const { env } = options.flags;
|
|
46
|
+
if (!env)
|
|
47
|
+
throw new UsageError(`${options.command} needs --env <name>`);
|
|
48
|
+
const entry = findEnvironment(options.services, env);
|
|
49
|
+
if (entry)
|
|
50
|
+
return entry;
|
|
51
|
+
const names = listEnvironments(options.services).map((candidate) => candidate.name);
|
|
52
|
+
throw new Error(`no environment named "${env}"${names.length > 0 ? `; there are: ${names.join(', ')}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
/** Looks up the environment's settings (Vault, prompts, ...) and connects. */
|
|
55
|
+
function connect(options, entry) {
|
|
56
|
+
const { timeout } = options.flags;
|
|
57
|
+
return connectEntry(entry, options.services, {
|
|
58
|
+
...(timeout === undefined ? {} : { statementTimeoutMs: timeout === 0 ? null : timeout * 1000 }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function withSession(options, work) {
|
|
62
|
+
const entry = environmentFor(options);
|
|
63
|
+
const session = await connect(options, entry);
|
|
64
|
+
try {
|
|
65
|
+
return await work(session, entry);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await session.close();
|
|
69
|
+
// Credentials issued for this command end with it.
|
|
70
|
+
await options.services.groups?.release(entry.name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function envs(options) {
|
|
74
|
+
const format = formatOf(options.flags);
|
|
75
|
+
const entries = listEnvironments(options.services);
|
|
76
|
+
if (format === 'json') {
|
|
77
|
+
options.out(`${JSON.stringify(entries.map((entry) => ({
|
|
78
|
+
name: entry.name,
|
|
79
|
+
source: entry.source,
|
|
80
|
+
tier: entry.tier,
|
|
81
|
+
protected: entry.protected,
|
|
82
|
+
readOnly: entry.readOnly,
|
|
83
|
+
address: addressOf(entry),
|
|
84
|
+
tls: entry.tlsMode,
|
|
85
|
+
})), null, 2)}\n`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
options.out(formatResult(result(['name', 'source', 'tier', 'access', 'address'], entries.map((entry) => [
|
|
89
|
+
entry.name,
|
|
90
|
+
entry.source,
|
|
91
|
+
entry.tier,
|
|
92
|
+
entry.readOnly ? 'read only' : 'read write',
|
|
93
|
+
addressOf(entry),
|
|
94
|
+
])), format));
|
|
95
|
+
}
|
|
96
|
+
const error = options.services.configError;
|
|
97
|
+
if (error)
|
|
98
|
+
options.err(`psql-explorer: ${error.message}\n`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
const json = (options, value) => {
|
|
102
|
+
options.out(`${JSON.stringify(value, null, 2)}\n`);
|
|
103
|
+
};
|
|
104
|
+
async function schemas(options, session) {
|
|
105
|
+
const format = formatOf(options.flags);
|
|
106
|
+
const found = await listSchemas(session);
|
|
107
|
+
if (format === 'json')
|
|
108
|
+
json(options, found);
|
|
109
|
+
else {
|
|
110
|
+
options.out(formatResult(result(['name', 'owner', 'tables'], found.map((schema) => [schema.name, schema.owner, String(schema.tables)])), format));
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
async function tables(options, session) {
|
|
115
|
+
const format = formatOf(options.flags);
|
|
116
|
+
const schema = options.flags.schema ?? session.params.schema;
|
|
117
|
+
const found = await listTables(session, schema);
|
|
118
|
+
if (format === 'json')
|
|
119
|
+
json(options, found);
|
|
120
|
+
else {
|
|
121
|
+
options.out(formatResult(result(['name', 'kind', 'rows', 'size', 'comment'], found.map((table) => [
|
|
122
|
+
table.name,
|
|
123
|
+
table.kind,
|
|
124
|
+
format === 'table' ? formatEstimate(table.estimatedRows) : text(table.estimatedRows),
|
|
125
|
+
format === 'table' ? formatBytes(table.totalBytes) : text(table.totalBytes),
|
|
126
|
+
table.comment,
|
|
127
|
+
])), format));
|
|
128
|
+
}
|
|
129
|
+
if (found.length === 0)
|
|
130
|
+
options.err(`psql-explorer: schema ${schema} has no tables\n`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const tableArgument = (options, session) => {
|
|
134
|
+
const [name] = options.args;
|
|
135
|
+
if (!name)
|
|
136
|
+
throw new UsageError(`${options.command} needs a table, e.g. users or app.users`);
|
|
137
|
+
return parseTableRef(name, options.flags.schema ?? session.params.schema);
|
|
138
|
+
};
|
|
139
|
+
async function describe(options, session) {
|
|
140
|
+
const format = formatOf(options.flags);
|
|
141
|
+
const details = await describeTable(session, tableArgument(options, session));
|
|
142
|
+
if (format === 'json') {
|
|
143
|
+
json(options, details);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
const columns = result(['column', 'type', 'nullable', 'default', 'key', 'comment'], details.columns.map((column) => [
|
|
147
|
+
column.name,
|
|
148
|
+
column.type,
|
|
149
|
+
column.nullable ? 'yes' : 'no',
|
|
150
|
+
column.default,
|
|
151
|
+
column.primaryKey ? 'PK' : null,
|
|
152
|
+
column.comment,
|
|
153
|
+
]));
|
|
154
|
+
const indexes = result(['index', 'definition', 'size'], details.indexes.map((index) => [index.name, index.definition, formatBytes(index.bytes)]));
|
|
155
|
+
const constraints = result(['constraint', 'kind', 'definition'], details.constraints.map((constraint) => [
|
|
156
|
+
constraint.name,
|
|
157
|
+
constraint.kind,
|
|
158
|
+
constraint.definition,
|
|
159
|
+
]));
|
|
160
|
+
if (format === 'csv') {
|
|
161
|
+
options.out(formatResult(columns, 'csv'));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
options.out(`Table ${tableLabel(details.table)}\n\n${formatResult(columns, 'table')}`);
|
|
165
|
+
if (details.indexes.length > 0)
|
|
166
|
+
options.out(`\nIndexes\n${formatResult(indexes, 'table')}`);
|
|
167
|
+
if (details.constraints.length > 0) {
|
|
168
|
+
options.out(`\nConstraints\n${formatResult(constraints, 'table')}`);
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const orderOf = (flags) => flags.order
|
|
173
|
+
? flags.order
|
|
174
|
+
.split(',')
|
|
175
|
+
.map((column) => ({ column: column.trim(), descending: flags.desc ?? false }))
|
|
176
|
+
: [];
|
|
177
|
+
async function rows(options, session) {
|
|
178
|
+
const format = formatOf(options.flags);
|
|
179
|
+
const table = tableArgument(options, session);
|
|
180
|
+
const limit = options.flags.limit ?? DEFAULT_PAGE_SIZE;
|
|
181
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
182
|
+
throw new UsageError('--limit must be at least 1');
|
|
183
|
+
const offset = options.flags.offset ?? 0;
|
|
184
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
185
|
+
throw new UsageError('--offset must be 0 or more');
|
|
186
|
+
const page = await browseRows(session, table, { limit, offset, orderBy: orderOf(options.flags) });
|
|
187
|
+
options.out(formatResult(page, format));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
async function sqlOf(options) {
|
|
191
|
+
const [given] = options.args;
|
|
192
|
+
if (given !== undefined && given !== '-')
|
|
193
|
+
return given;
|
|
194
|
+
if (given === undefined && options.stdinIsTTY) {
|
|
195
|
+
throw new UsageError('query needs SQL: query --env <name> "<sql>", or pipe it in');
|
|
196
|
+
}
|
|
197
|
+
return options.readStdin();
|
|
198
|
+
}
|
|
199
|
+
async function query(options, session, entry, sql) {
|
|
200
|
+
const format = formatOf(options.flags);
|
|
201
|
+
const maxRows = options.flags.maxRows ?? DEFAULT_MAX_ROWS;
|
|
202
|
+
const started = performance.now();
|
|
203
|
+
let ran;
|
|
204
|
+
try {
|
|
205
|
+
ran = await session.run(sql, [], { maxRows });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const message = describeQueryError(error);
|
|
209
|
+
options.services.store.recordQuery({
|
|
210
|
+
envName: entry.name,
|
|
211
|
+
sql,
|
|
212
|
+
durationMs: performance.now() - started,
|
|
213
|
+
error: message,
|
|
214
|
+
});
|
|
215
|
+
options.err(`psql-explorer: ${message}\n`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
options.services.store.recordQuery({
|
|
219
|
+
envName: entry.name,
|
|
220
|
+
sql,
|
|
221
|
+
durationMs: ran.durationMs,
|
|
222
|
+
rowCount: ran.rowCount ?? ran.rows.length,
|
|
223
|
+
});
|
|
224
|
+
if (ran.columns.length > 0)
|
|
225
|
+
options.out(formatResult(ran, format));
|
|
226
|
+
else if (format === 'json')
|
|
227
|
+
json(options, { command: ran.command, rowCount: ran.rowCount });
|
|
228
|
+
else if (format === 'table')
|
|
229
|
+
options.out(`${commandSummary(ran)}\n`);
|
|
230
|
+
if (ran.truncated) {
|
|
231
|
+
options.err(`psql-explorer: showing the first ${ran.rows.length} rows; raise --max-rows for more\n`);
|
|
232
|
+
}
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
async function info(options, session, entry) {
|
|
236
|
+
const format = formatOf(options.flags);
|
|
237
|
+
const server = await serverInfo(session);
|
|
238
|
+
if (format === 'json') {
|
|
239
|
+
json(options, { environment: entry.name, ...server });
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
options.out(formatResult(result(['setting', 'value'], [
|
|
243
|
+
['environment', entry.name],
|
|
244
|
+
['server', server.version],
|
|
245
|
+
['database', server.database],
|
|
246
|
+
['user', server.user],
|
|
247
|
+
['size', formatBytes(server.databaseBytes)],
|
|
248
|
+
['started', server.startedAt],
|
|
249
|
+
['read only', server.readOnly ? 'yes' : 'no'],
|
|
250
|
+
['time zone', server.timeZone],
|
|
251
|
+
['search path', server.searchPath],
|
|
252
|
+
['encoding', server.encoding],
|
|
253
|
+
]), format));
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
function history(options) {
|
|
257
|
+
const format = formatOf(options.flags);
|
|
258
|
+
const entry = environmentFor(options);
|
|
259
|
+
const records = options.services.store.historyFor(entry.name, options.flags.limit ?? 50);
|
|
260
|
+
if (format === 'json')
|
|
261
|
+
json(options, records);
|
|
262
|
+
else {
|
|
263
|
+
options.out(formatResult(result(['at', 'duration', 'rows', 'sql', 'error'], records.map((record) => [
|
|
264
|
+
record.at.slice(0, 19).replace('T', ' '),
|
|
265
|
+
formatDuration(record.durationMs),
|
|
266
|
+
text(record.rowCount),
|
|
267
|
+
record.sql,
|
|
268
|
+
record.error,
|
|
269
|
+
])), format));
|
|
270
|
+
}
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
/** Runs one command; never throws. 0 done, 1 failed, 2 wrong command line. */
|
|
274
|
+
export async function runCommand(options) {
|
|
275
|
+
try {
|
|
276
|
+
switch (options.command) {
|
|
277
|
+
case 'envs':
|
|
278
|
+
return envs(options);
|
|
279
|
+
case 'history':
|
|
280
|
+
return history(options);
|
|
281
|
+
case 'schemas':
|
|
282
|
+
return await withSession(options, (session) => schemas(options, session));
|
|
283
|
+
case 'tables':
|
|
284
|
+
return await withSession(options, (session) => tables(options, session));
|
|
285
|
+
case 'describe':
|
|
286
|
+
return await withSession(options, (session) => describe(options, session));
|
|
287
|
+
case 'rows':
|
|
288
|
+
return await withSession(options, (session) => rows(options, session));
|
|
289
|
+
case 'query': {
|
|
290
|
+
// Read before connecting, so a wrong command line costs no login.
|
|
291
|
+
formatOf(options.flags);
|
|
292
|
+
const sql = (await sqlOf(options)).trim();
|
|
293
|
+
if (!sql)
|
|
294
|
+
throw new UsageError('the SQL is empty');
|
|
295
|
+
return await withSession(options, (session, entry) => query(options, session, entry, sql));
|
|
296
|
+
}
|
|
297
|
+
case 'info':
|
|
298
|
+
return await withSession(options, (session, entry) => info(options, session, entry));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
const message = error instanceof UsageError ? error.message : describeQueryError(error);
|
|
303
|
+
options.err(`psql-explorer: ${message || messageOf(error)}\n`);
|
|
304
|
+
return error instanceof UsageError ? 2 : 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ResolveError } from '../config/resolvers/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Asks on the terminal, for commands run by a person: the question goes to stderr so stdout stays
|
|
4
|
+
* the command's output, and a secret is read without echo. One question at a time.
|
|
5
|
+
*/
|
|
6
|
+
export function createTerminalPrompter(input = process.stdin, output = process.stderr) {
|
|
7
|
+
let queue = Promise.resolve();
|
|
8
|
+
const ask = ({ envName, field, masked, challenge, signal }) => new Promise((resolve, reject) => {
|
|
9
|
+
let answer = '';
|
|
10
|
+
const wasRaw = input.isRaw;
|
|
11
|
+
const finish = (error) => {
|
|
12
|
+
input.off('data', onData);
|
|
13
|
+
signal?.removeEventListener('abort', onAbort);
|
|
14
|
+
input.setRawMode?.(wasRaw);
|
|
15
|
+
input.pause();
|
|
16
|
+
output.write('\n');
|
|
17
|
+
if (error)
|
|
18
|
+
reject(error);
|
|
19
|
+
else
|
|
20
|
+
resolve(answer);
|
|
21
|
+
};
|
|
22
|
+
const onAbort = () => {
|
|
23
|
+
finish(new ResolveError(envName, field, 'cancelled'));
|
|
24
|
+
};
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
for (const char of chunk.toString('utf8')) {
|
|
27
|
+
if (char === '\r' || char === '\n') {
|
|
28
|
+
if (answer)
|
|
29
|
+
finish();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (char === '\u0003' || char === '\u001b') {
|
|
33
|
+
finish(new ResolveError(envName, field, 'cancelled'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (char === '\u007f' || char === '\b') {
|
|
37
|
+
if (answer) {
|
|
38
|
+
answer = [...answer].slice(0, -1).join('');
|
|
39
|
+
output.write('\b \b');
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char < ' ')
|
|
44
|
+
continue;
|
|
45
|
+
answer += char;
|
|
46
|
+
output.write(masked ? '*' : char);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
if (signal?.aborted) {
|
|
50
|
+
reject(new ResolveError(envName, field, 'cancelled'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
output.write(challenge === undefined
|
|
54
|
+
? `${envName}.${field} (${masked ? 'secret' : 'value'}): `
|
|
55
|
+
: `${envName} is protected. Type "${challenge}" to continue: `);
|
|
56
|
+
input.setRawMode?.(true);
|
|
57
|
+
input.resume();
|
|
58
|
+
input.on('data', onData);
|
|
59
|
+
signal?.addEventListener('abort', onAbort);
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
ask(request) {
|
|
63
|
+
const next = queue.then(() => ask(request), () => ask(request));
|
|
64
|
+
queue = next.catch(() => undefined);
|
|
65
|
+
return next;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|