neonctl 2.22.2 → 2.23.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 (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +277 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +44 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. package/writer.test.js +0 -104
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Connection backslash commands.
3
+ *
4
+ * TypeScript port of the corresponding `exec_command_*` functions in
5
+ * upstream PostgreSQL's `src/bin/psql/command.c`:
6
+ *
7
+ * - `\c` / `\connect` → exec_command_connect / do_connect
8
+ * - `\conninfo` → exec_command_conninfo
9
+ * - `\encoding` → exec_command_encoding
10
+ * - `\password` → exec_command_password
11
+ *
12
+ * What this module owns:
13
+ *
14
+ * - Parsing the three `\c` argument shapes — positional (`db user host
15
+ * port`, with `-` meaning "keep current"), URI (`postgresql://…`), and
16
+ * conninfo key=value pairs (`dbname=foo host=bar`).
17
+ * - Building a fresh {@link ConnectOptions} by merging the override with
18
+ * the previous connection's opts and dispatching `PgConnection.connect`
19
+ * via an injectable seam so tests can stub the wire layer.
20
+ * - `\conninfo` — renders the PostgreSQL 18 "Connection Information"
21
+ * two-column table (Parameter / Value), mirroring upstream
22
+ * `exec_command_conninfo` in `src/bin/psql/command.c`. PG 18 rewrote
23
+ * `\conninfo` from the old one-line "You are connected to …" message
24
+ * into this table. Every value comes from the live connection's
25
+ * accessors (`getConnectionInfo()`, `getTlsInfo()`,
26
+ * `parameterStatus()`) — we issue NO SQL (the old form's
27
+ * `inet_server_addr()` returned a bogus internal IP behind a proxy like
28
+ * Neon). The ResultSet is handed to the active `\pset format` printer
29
+ * so `\conninfo` honours `-A`, `-H`, etc. just like a query result.
30
+ * - `\encoding` — like cmd_format.ts but additionally issues
31
+ * `SET client_encoding TO …` on the live connection so the backend
32
+ * ParameterStatus stays in sync with `settings.popt.topt.encoding`.
33
+ * - `\password` — prompts for a password (twice), encodes it as a
34
+ * SCRAM-SHA-256 verifier (RFC 5803 / PG's `ALTER USER … PASSWORD`
35
+ * format), and issues the ALTER USER on the live connection. The
36
+ * encoder lives in {@link scramSha256Verifier}; tests call it directly
37
+ * with a fixed salt so we don't have to mock crypto.randomBytes.
38
+ * Refuses to prompt when `settings.notty` is true (matches the spirit
39
+ * of upstream — we don't yet wire `/dev/tty`, so notty would otherwise
40
+ * fight the mainloop's stdin reader).
41
+ *
42
+ * What this module does NOT own:
43
+ *
44
+ * - Cataloguing the current database/user. These are populated by the
45
+ * startup WP into psql vars DBNAME/USER (and kept in sync by `\c`), so
46
+ * `\conninfo` reads them out of `settings.vars`, falling back to the
47
+ * connect-opts surfaced on the live connection. Host/port/hostaddr and
48
+ * the SSL facts come straight from the connection accessors.
49
+ *
50
+ * Password retention: `PgConnection` exposes the password captured at
51
+ * connect time via a read-only `password` getter (mirroring libpq's
52
+ * retention on the `PGconn`). `\c` reads it via a structural cast so we
53
+ * don't have to widen the frozen {@link Connection} interface, and feeds
54
+ * it to {@link mergeConnectOpts} so a reconnect to a different database
55
+ * works without re-prompting. A new password supplied in the conninfo /
56
+ * URI override always wins.
57
+ */
58
+ import { Buffer } from 'node:buffer';
59
+ import { createHash, createHmac, pbkdf2Sync, randomBytes as nodeRandomBytes, } from 'node:crypto';
60
+ import { readLine as readInputLine } from '../io/input.js';
61
+ import { syncConnectionVars } from '../core/syncVars.js';
62
+ import { PgConnection } from '../wire/connection.js';
63
+ import { alignedPrinter } from '../print/aligned.js';
64
+ import { asciidocPrinter } from '../print/asciidoc.js';
65
+ import { csvPrinter } from '../print/csv.js';
66
+ import { htmlPrinter } from '../print/html.js';
67
+ import { jsonPrinter } from '../print/json.js';
68
+ import { latexLongtablePrinter, latexPrinter } from '../print/latex.js';
69
+ import { troffMsPrinter } from '../print/troff.js';
70
+ import { unalignedPrinter } from '../print/unaligned.js';
71
+ import { writeErr, writeOut } from './shared.js';
72
+ const defaultDeps = {
73
+ connect: (opts) => PgConnection.connect(opts),
74
+ readLine: defaultReadLine,
75
+ randomBytes: nodeRandomBytes,
76
+ };
77
+ let currentDeps = defaultDeps;
78
+ /** Override the module's runtime deps. Returns a restore function. */
79
+ export const setCmdConnectDeps = (overrides) => {
80
+ const prev = currentDeps;
81
+ currentDeps = { ...prev, ...overrides };
82
+ return () => {
83
+ currentDeps = prev;
84
+ };
85
+ };
86
+ const readConnectionPassword = (conn) => {
87
+ if (!conn)
88
+ return null;
89
+ const raw = conn.password;
90
+ return typeof raw === 'string' && raw.length > 0 ? raw : null;
91
+ };
92
+ /**
93
+ * Read the live connection's EFFECTIVE {@link ConnectOptions} (the full set
94
+ * it was dialled with — sslmode, sslrootcert/cert/key/crl, sslnegotiation,
95
+ * channelBinding, requireAuth, hostaddr, …). The {@link Connection} interface
96
+ * deliberately hides these, but {@link PgConnection} keeps them on a (TS-)
97
+ * private `opts` field; we read it structurally, the same pattern as
98
+ * {@link readConnectionPassword}. Used to SEED a `\c` reconnect so it doesn't
99
+ * silently drop TLS/cert options or downgrade sslmode.
100
+ * Returns `null` for a mock/absent connection.
101
+ */
102
+ const readConnectionOptions = (conn) => {
103
+ if (!conn)
104
+ return null;
105
+ const raw = conn.opts;
106
+ return raw !== undefined && raw !== null && typeof raw === 'object'
107
+ ? raw
108
+ : null;
109
+ };
110
+ // ---------------------------------------------------------------------------
111
+ // \c / \connect
112
+ // ---------------------------------------------------------------------------
113
+ const KEEP = '-';
114
+ /**
115
+ * Parse the argument tail of `\c` into a partial override of
116
+ * {@link ConnectOptions}. Three forms:
117
+ *
118
+ * 1. **URI**: starts with `postgresql://` or `postgres://`.
119
+ * 2. **conninfo**: contains `=` in the first token — treated as a space
120
+ * separated `key=value` sequence (libpq's syntax; we don't implement
121
+ * quoted values because psql itself only forwards what the user typed
122
+ * to libpq's `PQconninfoParse`).
123
+ * 3. **positional**: `[db [user [host [port]]]]`. Each can be `-` to keep
124
+ * the current value.
125
+ *
126
+ * Returns an object with only the keys the user supplied. The caller is
127
+ * responsible for merging with the previous opts.
128
+ */
129
+ export const parseConnectArgs = (rawArgs) => {
130
+ const trimmed = rawArgs.trim();
131
+ if (trimmed.length === 0)
132
+ return {};
133
+ // URI form.
134
+ if (trimmed.startsWith('postgresql://') ||
135
+ trimmed.startsWith('postgres://')) {
136
+ return parseUri(trimmed);
137
+ }
138
+ // conninfo form — at least one token contains `=` (and that `=` is not
139
+ // inside a quoted SQL value).
140
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(trimmed) ||
141
+ /\s[A-Za-z_][A-Za-z0-9_]*=/.test(trimmed)) {
142
+ return parseConninfo(trimmed);
143
+ }
144
+ // Positional. Tokenise by whitespace; `-` is a sentinel.
145
+ const tokens = trimmed.split(/\s+/);
146
+ const out = {};
147
+ if (tokens.length >= 1 && tokens[0] !== KEEP)
148
+ out.database = tokens[0];
149
+ if (tokens.length >= 2 && tokens[1] !== KEEP)
150
+ out.user = tokens[1];
151
+ if (tokens.length >= 3 && tokens[2] !== KEEP)
152
+ out.host = tokens[2];
153
+ if (tokens.length >= 4 && tokens[3] !== KEEP) {
154
+ const port = parseInt(tokens[3], 10);
155
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
156
+ return { error: `invalid port number "${tokens[3]}"` };
157
+ }
158
+ out.port = port;
159
+ }
160
+ return out;
161
+ };
162
+ /**
163
+ * Apply a single libpq-style connection keyword to `out`, validating values
164
+ * where psql does. `key` must already be lowercased. Returns `{ error }` on a
165
+ * bad value, or `null` on success. Shared by the `key=value` conninfo parser
166
+ * and the `\c <uri>` query-string parser so both honour the same keywords.
167
+ */
168
+ const applyConnInfoKey = (out, key, value) => {
169
+ switch (key) {
170
+ case 'host':
171
+ out.host = value;
172
+ break;
173
+ case 'hostaddr':
174
+ // Distinct from `host`: hostaddr is the literal IP to dial, while the
175
+ // cert/SNI is still verified against `host`.
176
+ out.hostaddr = value;
177
+ break;
178
+ case 'port': {
179
+ const port = parseInt(value, 10);
180
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
181
+ return { error: `invalid port "${value}"` };
182
+ }
183
+ out.port = port;
184
+ break;
185
+ }
186
+ case 'user':
187
+ out.user = value;
188
+ break;
189
+ case 'password':
190
+ out.password = value;
191
+ break;
192
+ case 'dbname':
193
+ case 'database':
194
+ out.database = value;
195
+ break;
196
+ case 'application_name':
197
+ out.applicationName = value;
198
+ break;
199
+ case 'sslmode': {
200
+ const allowed = [
201
+ 'disable',
202
+ 'allow',
203
+ 'prefer',
204
+ 'require',
205
+ 'verify-ca',
206
+ 'verify-full',
207
+ ];
208
+ if (!allowed.includes(value)) {
209
+ return { error: `invalid sslmode "${value}"` };
210
+ }
211
+ out.ssl = value;
212
+ break;
213
+ }
214
+ case 'channel_binding': {
215
+ const allowed = [
216
+ 'disable',
217
+ 'prefer',
218
+ 'require',
219
+ ];
220
+ if (!allowed.includes(value)) {
221
+ return { error: `invalid channel_binding "${value}"` };
222
+ }
223
+ out.channelBinding = value;
224
+ break;
225
+ }
226
+ case 'client_encoding':
227
+ out.clientEncoding = value;
228
+ break;
229
+ case 'connect_timeout': {
230
+ const n = parseInt(value, 10);
231
+ if (!Number.isFinite(n) || n < 0) {
232
+ return { error: `invalid connect_timeout "${value}"` };
233
+ }
234
+ out.connectTimeoutMs = n * 1000;
235
+ break;
236
+ }
237
+ case 'options':
238
+ out.options = value;
239
+ break;
240
+ default:
241
+ // Unknown keys are silently ignored — matches libpq's permissiveness
242
+ // for forward-compat with future PG releases. We could warn here,
243
+ // but psql itself doesn't.
244
+ break;
245
+ }
246
+ return null;
247
+ };
248
+ const parseUri = (raw) => {
249
+ let url;
250
+ try {
251
+ // Node's URL parser accepts `postgresql://` schemes.
252
+ url = new URL(raw);
253
+ }
254
+ catch (err) {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ return { error: `invalid URI: ${msg}` };
257
+ }
258
+ const out = {};
259
+ // decodeURIComponent throws URIError on malformed `%` escapes (e.g. `%zz`);
260
+ // wrap every decode so a bad URI surfaces as a clean error rather than an
261
+ // unhandled exception that aborts the REPL.
262
+ try {
263
+ if (url.hostname.length > 0)
264
+ out.host = decodeURIComponent(url.hostname);
265
+ if (url.port.length > 0) {
266
+ const port = parseInt(url.port, 10);
267
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
268
+ return { error: `invalid port in URI` };
269
+ }
270
+ out.port = port;
271
+ }
272
+ if (url.username.length > 0)
273
+ out.user = decodeURIComponent(url.username);
274
+ if (url.password.length > 0) {
275
+ out.password = decodeURIComponent(url.password);
276
+ }
277
+ const pathname = url.pathname.replace(/^\//, '');
278
+ if (pathname.length > 0)
279
+ out.database = decodeURIComponent(pathname);
280
+ // Map the query string (?sslmode=require&connect_timeout=10&…) onto the
281
+ // same connection keywords libpq accepts in a URI.
282
+ for (const [rawKey, rawVal] of url.searchParams) {
283
+ const res = applyConnInfoKey(out, rawKey.toLowerCase(), rawVal);
284
+ if (res !== null)
285
+ return res;
286
+ }
287
+ }
288
+ catch (err) {
289
+ const msg = err instanceof Error ? err.message : String(err);
290
+ return { error: `invalid URI: ${msg}` };
291
+ }
292
+ return out;
293
+ };
294
+ const parseConninfo = (raw) => {
295
+ const out = {};
296
+ // Simple key=value tokenizer — splits on whitespace not inside single
297
+ // quotes. libpq supports backslash-escapes inside quotes; we accept them
298
+ // verbatim for now (none of the keys we care about typically need them).
299
+ const pairs = [];
300
+ let current = '';
301
+ let inQuote = false;
302
+ for (let i = 0; i < raw.length; i++) {
303
+ const c = raw[i];
304
+ if (inQuote) {
305
+ if (c === '\\' && i + 1 < raw.length) {
306
+ current += raw[i + 1];
307
+ i++;
308
+ continue;
309
+ }
310
+ if (c === "'") {
311
+ inQuote = false;
312
+ continue;
313
+ }
314
+ current += c;
315
+ continue;
316
+ }
317
+ if (c === "'") {
318
+ inQuote = true;
319
+ continue;
320
+ }
321
+ if (/\s/.test(c)) {
322
+ if (current.length > 0) {
323
+ pairs.push(current);
324
+ current = '';
325
+ }
326
+ continue;
327
+ }
328
+ current += c;
329
+ }
330
+ if (current.length > 0)
331
+ pairs.push(current);
332
+ for (const pair of pairs) {
333
+ const eq = pair.indexOf('=');
334
+ if (eq < 0) {
335
+ return { error: `missing "=" after "${pair}" in connection info` };
336
+ }
337
+ const key = pair.slice(0, eq).toLowerCase();
338
+ const value = pair.slice(eq + 1);
339
+ const res = applyConnInfoKey(out, key, value);
340
+ if (res !== null)
341
+ return res;
342
+ }
343
+ return out;
344
+ };
345
+ /**
346
+ * Build a full {@link ConnectOptions} for the new connection by merging
347
+ * `override` over the "previous" connection state. The previous values are
348
+ * sourced from `settings.vars` (HOST/PORT/USER/DBNAME/PASSWORD) which the
349
+ * startup WP populates; for fields not represented there (sslmode, etc.)
350
+ * we use safe defaults.
351
+ *
352
+ * For passwords specifically the precedence is:
353
+ * 1. `override.password` — anything the user typed in this `\c` invocation
354
+ * (URI password or `password=` conninfo key) wins.
355
+ * 2. `previousPassword` — the password captured on the live
356
+ * {@link PgConnection}, mirroring libpq's behaviour of retaining the
357
+ * credential on the `PGconn` so reconnects work transparently.
358
+ * 3. `PASSWORD` psql var — set by `-W` / `PGPASSWORD` at startup.
359
+ *
360
+ * Returns `null` if the merge can't produce a usable opts (e.g. no database
361
+ * and no current connection).
362
+ */
363
+ export const mergeConnectOpts = (settings, override, prior = null) => {
364
+ const vars = settings.vars;
365
+ // Overlay only the keys the user actually supplied (drop `undefined`s so a
366
+ // spread doesn't clobber a seeded value with `undefined`).
367
+ const ov = {};
368
+ for (const [k, v] of Object.entries(override)) {
369
+ if (v !== undefined)
370
+ ov[k] = v;
371
+ }
372
+ // libpq's do_connect clones the prior connection's full conninfo and
373
+ // overrides only user-specified keys — so a reconnect keeps sslmode,
374
+ // sslrootcert/cert/key/crl, sslnegotiation, channelBinding, requireAuth,
375
+ // hostaddr, etc. Seed from the live connection's effective options; fall
376
+ // back to {} when there is none.
377
+ const seed = prior ? { ...prior } : {};
378
+ // libpq clears keep_password when user, host, OR port changes, so a stored
379
+ // credential isn't transmitted to a different principal/server. Compare the
380
+ // override against the prior live target.
381
+ const hostChanged = ov.host !== undefined && ov.host !== prior?.host;
382
+ const targetChanged = hostChanged ||
383
+ (ov.port !== undefined && ov.port !== prior?.port) ||
384
+ (ov.user !== undefined && ov.user !== prior?.user);
385
+ if (targetChanged)
386
+ delete seed.password;
387
+ // A new host invalidates the prior hostaddr (it was the old host's IP).
388
+ if (hostChanged)
389
+ delete seed.hostaddr;
390
+ const merged = { ...seed, ...ov };
391
+ // Fill the required fields from vars / env / defaults when neither the
392
+ // override nor the prior connection supplied them.
393
+ const host = merged.host ?? vars.get('HOST') ?? 'localhost';
394
+ const portStr = vars.get('PORT');
395
+ const port = merged.port ?? (portStr !== undefined ? parseInt(portStr, 10) : 5432);
396
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
397
+ return { error: `invalid port number` };
398
+ }
399
+ const user = merged.user ??
400
+ vars.get('USER') ??
401
+ process.env.USER ??
402
+ process.env.USERNAME ??
403
+ '';
404
+ if (user.length === 0) {
405
+ return { error: 'no user name specified' };
406
+ }
407
+ const database = merged.database ?? vars.get('DBNAME') ?? user;
408
+ const password = merged.password ?? vars.get('PASSWORD');
409
+ const ssl = merged.ssl ?? 'prefer';
410
+ return {
411
+ ...merged,
412
+ host,
413
+ port,
414
+ user,
415
+ password: password ?? undefined,
416
+ database,
417
+ ssl,
418
+ applicationName: merged.applicationName ?? vars.get('APPLICATION_NAME'),
419
+ clientEncoding: merged.clientEncoding ?? settings.popt.topt.encoding,
420
+ };
421
+ };
422
+ /** `\c` / `\connect` — reconnect, possibly to a different database/user/host. */
423
+ export const cmdConnect = {
424
+ name: 'c',
425
+ aliases: ['connect'],
426
+ argMode: 'whole-line',
427
+ helpKey: 'c',
428
+ run: async (ctx) => {
429
+ const rawArgs = ctx.restOfLine();
430
+ if (rawArgs.trim().length === 0) {
431
+ // No args → print conninfo, matching upstream `do_connect` short
432
+ // circuit.
433
+ return runConninfo(ctx);
434
+ }
435
+ const parsed = parseConnectArgs(rawArgs);
436
+ if ('error' in parsed) {
437
+ writeErr(`\\${ctx.cmdName}: ${parsed.error}\n`);
438
+ return { status: 'error' };
439
+ }
440
+ // Seed the reconnect from the live connection's EFFECTIVE options so TLS /
441
+ // cert / auth settings survive and the prior password is only
442
+ // reused when the principal/server is unchanged. Both are read
443
+ // structurally from PgConnection (the frozen Connection interface hides
444
+ // them). readConnectionPassword stays as the fallback for mocks/drivers
445
+ // that expose `password` but not `opts`.
446
+ const priorOpts = readConnectionOptions(ctx.settings.db);
447
+ const priorPw = readConnectionPassword(ctx.settings.db);
448
+ const prior = priorOpts ?? (priorPw !== null ? { password: priorPw } : null);
449
+ const newOpts = mergeConnectOpts(ctx.settings, parsed, prior);
450
+ if ('error' in newOpts) {
451
+ writeErr(`\\${ctx.cmdName}: ${newOpts.error}\n`);
452
+ return { status: 'error' };
453
+ }
454
+ let next;
455
+ try {
456
+ next = await currentDeps.connect(newOpts);
457
+ }
458
+ catch (err) {
459
+ // psql keeps the old connection on failure.
460
+ const msg = err instanceof Error ? err.message : String(err);
461
+ writeErr(`\\${ctx.cmdName}: connection failed: ${msg}\n`);
462
+ return { status: 'error' };
463
+ }
464
+ const old = ctx.settings.db;
465
+ ctx.settings.db = next;
466
+ // Mirror upstream do_connect → SyncVariables(): refresh the
467
+ // connection-driven psql vars (DBNAME/USER/HOST/PORT/ENCODING/
468
+ // SERVER_VERSION_*) from the new live connection so subsequent
469
+ // `:DBNAME`/`:USER`/etc. interpolations reflect the reconnect target.
470
+ syncConnectionVars(ctx.settings.vars, next);
471
+ if (old && !old.isClosed()) {
472
+ try {
473
+ await old.close();
474
+ }
475
+ catch {
476
+ // Old connection may already be in a weird state; we've moved on.
477
+ }
478
+ }
479
+ writeOut(`You are now connected to database "${newOpts.database}" as user "${newOpts.user}".\n`);
480
+ return { status: 'ok' };
481
+ },
482
+ };
483
+ // ---------------------------------------------------------------------------
484
+ // \conninfo
485
+ // ---------------------------------------------------------------------------
486
+ /**
487
+ * Pick the printer for the active output format. Mirrors the private
488
+ * `pickPrinter` in `core/common.ts` / `pickActivePrinter` in `cmd_io.ts`
489
+ * — replicated here (those are module-private) to avoid an import cycle,
490
+ * the same established pattern this codebase already uses for the two
491
+ * other copies.
492
+ */
493
+ const pickActivePrinter = (settings) => {
494
+ switch (settings.popt.topt.format) {
495
+ case 'aligned':
496
+ case 'wrapped':
497
+ return alignedPrinter;
498
+ case 'unaligned':
499
+ return unalignedPrinter;
500
+ case 'csv':
501
+ return csvPrinter;
502
+ case 'json':
503
+ return jsonPrinter;
504
+ case 'html':
505
+ return htmlPrinter;
506
+ case 'asciidoc':
507
+ return asciidocPrinter;
508
+ case 'latex':
509
+ return latexPrinter;
510
+ case 'latex-longtable':
511
+ return latexLongtablePrinter;
512
+ case 'troff-ms':
513
+ return troffMsPrinter;
514
+ default:
515
+ return alignedPrinter;
516
+ }
517
+ };
518
+ /** A text-typed field descriptor for the synthetic conninfo ResultSet. */
519
+ const textField = (name) => ({
520
+ name,
521
+ tableID: 0,
522
+ columnID: 0,
523
+ // OID 25 = `text`: left-aligned, like both columns of upstream's output.
524
+ dataTypeID: 25,
525
+ dataTypeSize: -1,
526
+ dataTypeModifier: -1,
527
+ format: 0,
528
+ });
529
+ /**
530
+ * Build the (Parameter, Value) rows for the PG18 connection-information
531
+ * table. Mirrors the row order and gating of upstream
532
+ * `exec_command_conninfo` (`src/bin/psql/command.c`).
533
+ */
534
+ const buildConninfoRows = (ctx, db) => {
535
+ const info = db.getConnectionInfo?.() ?? null;
536
+ const tls = db.getTlsInfo?.() ?? null;
537
+ // Database / user come from the psql vars the startup WP populates (and
538
+ // that `\c` keeps in sync); fall back to the connect-opts surfaced on the
539
+ // connection when a var is missing.
540
+ const database = ctx.settings.vars.get('DBNAME') ??
541
+ db.database ??
542
+ '';
543
+ const user = ctx.settings.vars.get('USER') ??
544
+ db.user ??
545
+ '';
546
+ const host = info?.host ?? ctx.settings.vars.get('HOST') ?? '';
547
+ const hostaddr = info?.hostaddr ?? null;
548
+ const port = info?.port ?? Number(ctx.settings.vars.get('PORT') ?? Number.NaN);
549
+ const portStr = Number.isFinite(port) ? String(port) : '';
550
+ const rows = [];
551
+ rows.push(['Database', database]);
552
+ rows.push(['Client User', user]);
553
+ // Host rows. A Unix-domain socket path (starts with '/') prints a
554
+ // "Socket Directory" (or "Host Address" when a hostaddr was fixed);
555
+ // otherwise "Host", plus a separate "Host Address" only when a distinct
556
+ // hostaddr is present.
557
+ if (host.startsWith('/')) {
558
+ if (hostaddr !== null && hostaddr.length > 0) {
559
+ rows.push(['Host Address', hostaddr]);
560
+ }
561
+ else {
562
+ rows.push(['Socket Directory', host]);
563
+ }
564
+ }
565
+ else {
566
+ rows.push(['Host', host]);
567
+ if (hostaddr !== null && hostaddr.length > 0 && hostaddr !== host) {
568
+ rows.push(['Host Address', hostaddr]);
569
+ }
570
+ }
571
+ rows.push(['Server Port', portStr]);
572
+ rows.push(['Options', info?.options ?? '']);
573
+ rows.push(['Protocol Version', '3.0']);
574
+ rows.push(['Password Used', info?.passwordUsed ? 'true' : 'false']);
575
+ rows.push(['GSSAPI Authenticated', info?.gssapiUsed ? 'true' : 'false']);
576
+ rows.push(['Backend PID', String(info?.backendPid ?? 0)]);
577
+ rows.push(['SSL Connection', tls ? 'true' : 'false']);
578
+ if (tls) {
579
+ rows.push(['SSL Library', tls.library]);
580
+ rows.push(['SSL Protocol', tls.protocol]);
581
+ rows.push([
582
+ 'SSL Key Bits',
583
+ tls.keyBits !== null ? String(tls.keyBits) : '',
584
+ ]);
585
+ rows.push(['SSL Cipher', tls.cipher]);
586
+ rows.push([
587
+ 'SSL Compression',
588
+ tls.compression !== 'off' ? 'true' : 'false',
589
+ ]);
590
+ rows.push(['ALPN', tls.alpn && tls.alpn.length > 0 ? tls.alpn : 'none']);
591
+ }
592
+ rows.push([
593
+ 'Superuser',
594
+ ctx.settings.db?.parameterStatus('is_superuser') ?? 'unknown',
595
+ ]);
596
+ rows.push([
597
+ 'Hot Standby',
598
+ ctx.settings.db?.parameterStatus('in_hot_standby') ?? 'unknown',
599
+ ]);
600
+ return rows;
601
+ };
602
+ const runConninfo = async (ctx) => {
603
+ const db = ctx.settings.db;
604
+ if (!db) {
605
+ writeOut('You are currently not connected to a database.\n');
606
+ return { status: 'ok' };
607
+ }
608
+ const rows = buildConninfoRows(ctx, db);
609
+ const rs = {
610
+ command: 'SELECT',
611
+ rowCount: rows.length,
612
+ oid: null,
613
+ fields: [textField('Parameter'), textField('Value')],
614
+ rows: rows.map(([p, v]) => [p, v]),
615
+ notices: [],
616
+ };
617
+ // Render with the active printer (honours `\pset format`), titled
618
+ // "Connection Information" with the default `(N rows)` footer left on,
619
+ // matching PG18's `printQuery(... title = "Connection Information")`.
620
+ const popt = {
621
+ ...ctx.settings.popt,
622
+ title: 'Connection Information',
623
+ topt: {
624
+ ...ctx.settings.popt.topt,
625
+ title: 'Connection Information',
626
+ defaultFooter: true,
627
+ },
628
+ };
629
+ const out = process.stdout;
630
+ await pickActivePrinter(ctx.settings).printQuery(rs, popt, out);
631
+ return { status: 'ok' };
632
+ };
633
+ /** `\conninfo` — print info about the current connection. */
634
+ export const cmdConninfo = {
635
+ name: 'conninfo',
636
+ helpKey: 'conninfo',
637
+ run: (ctx) => runConninfo(ctx),
638
+ };
639
+ // ---------------------------------------------------------------------------
640
+ // \encoding — overrides cmd_format.ts's version so the live connection
641
+ // also sees the new client_encoding (via SET client_encoding TO …).
642
+ // ---------------------------------------------------------------------------
643
+ /** `\encoding [NAME]` — show or set the client encoding, propagating to the connection. */
644
+ export const cmdEncoding = {
645
+ name: 'encoding',
646
+ helpKey: 'encoding',
647
+ run: async (ctx) => {
648
+ const arg = ctx.nextArg('normal');
649
+ const topt = ctx.settings.popt.topt;
650
+ if (arg === null) {
651
+ writeOut(`${topt.encoding}\n`);
652
+ return { status: 'ok' };
653
+ }
654
+ const db = ctx.settings.db;
655
+ if (db && !db.isClosed()) {
656
+ try {
657
+ // PG accepts both quoted and unquoted encoding names; we use the
658
+ // quoted form for safety. Encoding names are ASCII identifiers.
659
+ await db.execSimple(`SET client_encoding TO ${db.escapeLiteral(arg)}`);
660
+ }
661
+ catch (err) {
662
+ const msg = err instanceof Error ? err.message : String(err);
663
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
664
+ return { status: 'error' };
665
+ }
666
+ }
667
+ topt.encoding = arg;
668
+ ctx.settings.vars.set('ENCODING', arg);
669
+ return { status: 'ok' };
670
+ },
671
+ };
672
+ // ---------------------------------------------------------------------------
673
+ // \password
674
+ // ---------------------------------------------------------------------------
675
+ /**
676
+ * Encode a password into PostgreSQL's SCRAM-SHA-256 verifier format, the
677
+ * value `ALTER ROLE … PASSWORD '…'` expects when the server is configured
678
+ * with `password_encryption = scram-sha-256` (the default since PG 14):
679
+ *
680
+ * SCRAM-SHA-256$<iterations>:<base64 salt>$<base64 storedKey>:<base64 serverKey>
681
+ *
682
+ * Derivation follows RFC 5802 §3:
683
+ *
684
+ * SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
685
+ * ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
686
+ * StoredKey = SHA256(ClientKey)
687
+ * ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
688
+ *
689
+ * Sending the encoded form (rather than the plaintext password) means the
690
+ * server never sees the password, even briefly, and the SCRAM verifier is
691
+ * stored as-is in `pg_authid.rolpassword`.
692
+ *
693
+ * Iterations default to 4096 to match PG's `scram_iterations` default and
694
+ * libpq's `PQchangePassword`. The caller can override (lower for tests,
695
+ * higher for stronger hardening).
696
+ */
697
+ export const scramSha256Verifier = (password, salt, iterations = 4096) => {
698
+ const saltedPassword = pbkdf2Sync(Buffer.from(password, 'utf8'), salt, iterations, 32, 'sha256');
699
+ const clientKey = createHmac('sha256', saltedPassword)
700
+ .update('Client Key')
701
+ .digest();
702
+ const storedKey = createHash('sha256').update(clientKey).digest();
703
+ const serverKey = createHmac('sha256', saltedPassword)
704
+ .update('Server Key')
705
+ .digest();
706
+ return ('SCRAM-SHA-256$' +
707
+ String(iterations) +
708
+ ':' +
709
+ salt.toString('base64') +
710
+ '$' +
711
+ storedKey.toString('base64') +
712
+ ':' +
713
+ serverKey.toString('base64'));
714
+ };
715
+ /**
716
+ * Default password / prompt reader. Delegates to the shared input layer
717
+ * ({@link readInputLine}), which suppresses echo on a TTY and falls back to a
718
+ * plain line read otherwise. Kept as a named function so the test seam in
719
+ * {@link CmdConnectDeps} can swap it out.
720
+ */
721
+ function defaultReadLine(prompt, opts) {
722
+ return readInputLine(prompt, { echo: opts.echo });
723
+ }
724
+ /** `\password [USERNAME]` — change a role's password, locally hashed as SCRAM. */
725
+ export const cmdPassword = {
726
+ name: 'password',
727
+ helpKey: 'password',
728
+ run: async (ctx) => {
729
+ const db = ctx.settings.db;
730
+ if (!db || db.isClosed()) {
731
+ writeErr(`\\${ctx.cmdName}: not connected\n`);
732
+ return { status: 'error' };
733
+ }
734
+ // Refuse to prompt for a password when stdin is not a TTY. Upstream uses
735
+ // /dev/tty as a fallback so it works even with piped stdin; we don't yet
736
+ // wire that path, so notty is a hard error (it would otherwise corrupt
737
+ // the mainloop's own readline iterator on stdin). Tests mock the prompt
738
+ // via `readLine` and run with `notty: false`, the `defaultSettings`
739
+ // default.
740
+ if (ctx.settings.notty) {
741
+ writeErr(`\\${ctx.cmdName}: not in interactive mode\n`);
742
+ return { status: 'error' };
743
+ }
744
+ const userArg = ctx.nextArg('sql-id');
745
+ let user = userArg;
746
+ if (user === null) {
747
+ try {
748
+ const rs = await db.query('SELECT CURRENT_USER');
749
+ const row = rs.rows[0] ?? [];
750
+ user = typeof row[0] === 'string' ? row[0] : null;
751
+ }
752
+ catch (err) {
753
+ const msg = err instanceof Error ? err.message : String(err);
754
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
755
+ return { status: 'error' };
756
+ }
757
+ if (!user) {
758
+ writeErr(`\\${ctx.cmdName}: could not determine current user\n`);
759
+ return { status: 'error' };
760
+ }
761
+ }
762
+ let pw1;
763
+ let pw2;
764
+ try {
765
+ pw1 = await currentDeps.readLine(`Enter new password for user "${user}": `, { echo: false });
766
+ pw2 = await currentDeps.readLine('Enter it again: ', { echo: false });
767
+ }
768
+ catch (err) {
769
+ const msg = err instanceof Error ? err.message : String(err);
770
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
771
+ return { status: 'error' };
772
+ }
773
+ if (pw1 !== pw2) {
774
+ writeErr(`\\${ctx.cmdName}: Passwords didn't match.\n`);
775
+ return { status: 'error' };
776
+ }
777
+ if (pw1.length === 0) {
778
+ writeErr(`\\${ctx.cmdName}: empty password\n`);
779
+ return { status: 'error' };
780
+ }
781
+ // Match upstream's PQchangePassword: `ALTER USER <id> PASSWORD <lit>`.
782
+ // (ALTER USER and ALTER ROLE are synonyms in PG, but we follow the
783
+ // libpq spelling for byte-for-byte parity with vanilla psql.)
784
+ const salt = currentDeps.randomBytes(16);
785
+ const verifier = scramSha256Verifier(pw1, salt);
786
+ const sql = 'ALTER USER ' +
787
+ db.escapeIdentifier(user) +
788
+ ' PASSWORD ' +
789
+ db.escapeLiteral(verifier);
790
+ try {
791
+ await db.execSimple(sql);
792
+ }
793
+ catch (err) {
794
+ const msg = err instanceof Error ? err.message : String(err);
795
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
796
+ return { status: 'error' };
797
+ }
798
+ return { status: 'ok' };
799
+ },
800
+ };
801
+ // ---------------------------------------------------------------------------
802
+ // Registry hook
803
+ // ---------------------------------------------------------------------------
804
+ /**
805
+ * Register the four connection commands on the given registry. Called from
806
+ * `dispatch.ts::defaultRegistry()`. Re-registering an existing primary name
807
+ * overrides — that's how we replace the no-op `\encoding` from cmd_format
808
+ * with this WP's version that propagates to the connection.
809
+ */
810
+ export const registerConnectCommands = (registry) => {
811
+ registry.register(cmdConnect);
812
+ registry.register(cmdConninfo);
813
+ registry.register(cmdEncoding);
814
+ registry.register(cmdPassword);
815
+ };