neonctl 2.22.2 → 2.23.1

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 +268 -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 +43 -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,970 @@
1
+ /**
2
+ * Meta backslash commands.
3
+ *
4
+ * TypeScript port of the corresponding `exec_command_*` functions in
5
+ * upstream PostgreSQL's `src/bin/psql/command.c`:
6
+ *
7
+ * - `\q` / `\quit` → exec_command_quit
8
+ * - `\r` / `\reset` → exec_command_reset
9
+ * - `\!` → exec_command_shell_escape (do_shell)
10
+ * - `\cd` → exec_command_cd
11
+ * - `\echo`, `\qecho`, `\warn` → exec_command_echo / qecho / warn
12
+ * - `\prompt` → exec_command_prompt
13
+ * - `\set`, `\unset` → exec_command_set / exec_command_unset
14
+ * - `\getenv`, `\setenv` → exec_command_getenv / exec_command_setenv
15
+ * - `\errverbose` → exec_command_errverbose
16
+ * - `\timing` → exec_command_timing
17
+ * - `\copyright` → exec_command_copyright
18
+ * - `\h` / `\help` → exec_command_help (helpSQL)
19
+ *
20
+ * Each command is exported as a `BackslashCmdSpec` so {@link defaultRegistry}
21
+ * in `dispatch.ts` can register them. Error messages follow upstream's
22
+ * `\<cmd>: <message>` shape and go to stderr; on failure we return
23
+ * `{ status: 'error' }`. Successful invocations return `{ status: 'ok' }`.
24
+ *
25
+ * Stubs / deferred behaviour:
26
+ *
27
+ * - `\!` always returns `{ status: 'ok' }` — upstream does not propagate
28
+ * the child's exit status to the surrounding script, only the run-mode.
29
+ * Tests use a stdio mock; in interactive use the child inherits stdio.
30
+ * - `\prompt -` (no-echo password prompting) reads via the shared input
31
+ * layer with echo suppressed on a TTY (falling back to a plain read on
32
+ * non-interactive input).
33
+ * - `\qecho` writes to `settings.logfile` if set, else stdout. Upstream
34
+ * additionally honours a separate "query output" file set via `\o`;
35
+ * that wiring lives in WP-15 and we leave the hook in place.
36
+ */
37
+ import { spawnSync } from 'node:child_process';
38
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import { join } from 'node:path';
41
+ import { readLine } from '../io/input.js';
42
+ import { getHistory } from '../io/history.js';
43
+ import { slashUsage } from '../core/help.js';
44
+ import { helpSQL } from '../core/help.js';
45
+ import { writeOut, writeErr, parseBool } from './shared.js';
46
+ /** `\q` / `\quit` — exit the REPL. */
47
+ export const cmdQuit = {
48
+ name: 'q',
49
+ aliases: ['quit'],
50
+ helpKey: 'q',
51
+ run: () => Promise.resolve({ status: 'exit' }),
52
+ };
53
+ /**
54
+ * `\!` — shell escape. Whole-line mode: the entire rest of the line is the
55
+ * command string.
56
+ *
57
+ * - `\!` (no args) → spawn `$SHELL -i` (fallback `sh -i`)
58
+ * - `\!command args` → spawn `sh -c 'command args'`
59
+ *
60
+ * In both cases the child inherits stdio and we return `{ status: 'ok' }`
61
+ * regardless of the child's exit status — matching upstream `do_shell`,
62
+ * which keeps the REPL alive after a failing shell command rather than
63
+ * propagating the exit code. Catching a spawn-time exception keeps us
64
+ * resilient against environments where `sh` is unavailable.
65
+ */
66
+ export const cmdShell = {
67
+ name: '!',
68
+ argMode: 'whole-line',
69
+ helpKey: '!',
70
+ run: (ctx) => {
71
+ const line = ctx.restOfLine().trim();
72
+ try {
73
+ if (line.length === 0) {
74
+ const shell = process.env.SHELL ?? '/bin/sh';
75
+ spawnSync(shell, ['-i'], { stdio: 'inherit' });
76
+ }
77
+ else {
78
+ spawnSync('sh', ['-c', line], { stdio: 'inherit' });
79
+ }
80
+ }
81
+ catch {
82
+ // Upstream `do_shell` swallows shell-spawn failures: the REPL has to
83
+ // keep running even when the child won't start. Status stays `ok` so
84
+ // a failing `\!` is purely informational.
85
+ }
86
+ return Promise.resolve({ status: 'ok' });
87
+ },
88
+ };
89
+ /** `\cd [dir]` — change cwd. No arg falls back to `$HOME`. */
90
+ export const cmdCd = {
91
+ name: 'cd',
92
+ helpKey: 'cd',
93
+ run: (ctx) => {
94
+ const dir = ctx.nextArg('normal');
95
+ const target = dir && dir.length > 0 ? dir : (process.env.HOME ?? null);
96
+ if (!target) {
97
+ writeErr(`\\${ctx.cmdName}: could not determine home directory\n`);
98
+ return Promise.resolve({ status: 'error' });
99
+ }
100
+ try {
101
+ process.chdir(target);
102
+ return Promise.resolve({ status: 'ok' });
103
+ }
104
+ catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
107
+ return Promise.resolve({ status: 'error' });
108
+ }
109
+ },
110
+ };
111
+ /**
112
+ * Helper for `\echo` / `\qecho` / `\warn`. Reads args until exhausted,
113
+ * honours the leading `-n` flag (suppresses trailing newline), joins with
114
+ * single spaces, and writes to the chosen stream.
115
+ *
116
+ * Upstream `exec_command_echo` only treats `-n` as a flag when the source
117
+ * was the unquoted two-character token `-n`. `'-n'` (single-quoted) is a
118
+ * literal value: it should be printed AND the trailing newline kept. We
119
+ * inspect `ctx.rawArgs` directly because `nextArg` discards quote
120
+ * metadata after lexing.
121
+ */
122
+ const runEcho = (ctx, write) => {
123
+ const parts = [];
124
+ let noNewline = false;
125
+ let first = true;
126
+ // Pre-scan the raw text to decide whether the first arg was the
127
+ // unquoted `-n` token. We can't rely on the lexed arg value alone:
128
+ // `'-n'` / `"-n"` produce the same string but must be treated as data.
129
+ const firstArgIsUnquotedDashN = (() => {
130
+ let i = 0;
131
+ while (i < ctx.rawArgs.length && /\s/.test(ctx.rawArgs[i]))
132
+ i++;
133
+ return (ctx.rawArgs.slice(i, i + 2) === '-n' &&
134
+ (i + 2 === ctx.rawArgs.length || /\s/.test(ctx.rawArgs[i + 2])));
135
+ })();
136
+ for (;;) {
137
+ const arg = ctx.nextArg('normal');
138
+ if (arg === null)
139
+ break;
140
+ if (first && firstArgIsUnquotedDashN && arg === '-n') {
141
+ noNewline = true;
142
+ first = false;
143
+ continue;
144
+ }
145
+ first = false;
146
+ parts.push(arg);
147
+ }
148
+ const out = parts.join(' ') + (noNewline ? '' : '\n');
149
+ write(out);
150
+ return { status: 'ok' };
151
+ };
152
+ /** `\echo` — write args to stdout. */
153
+ export const cmdEcho = {
154
+ name: 'echo',
155
+ helpKey: 'echo',
156
+ run: (ctx) => Promise.resolve(runEcho(ctx, writeOut)),
157
+ };
158
+ /** `\qecho` — write args to the query output (logfile if set, else stdout). */
159
+ export const cmdQecho = {
160
+ name: 'qecho',
161
+ helpKey: 'qecho',
162
+ run: (ctx) => {
163
+ const { logfile } = ctx.settings;
164
+ const write = (s) => {
165
+ if (logfile) {
166
+ logfile.write(s);
167
+ }
168
+ else {
169
+ writeOut(s);
170
+ }
171
+ };
172
+ return Promise.resolve(runEcho(ctx, write));
173
+ },
174
+ };
175
+ /** `\warn` — write args to stderr. */
176
+ export const cmdWarn = {
177
+ name: 'warn',
178
+ helpKey: 'warn',
179
+ run: (ctx) => Promise.resolve(runEcho(ctx, writeErr)),
180
+ };
181
+ /**
182
+ * `\prompt [TEXT] varname`
183
+ *
184
+ * Upstream: read one line of input from the terminal, optionally with a
185
+ * prompt prefix, and assign it to a psql variable. A leading `-` flag
186
+ * requests a no-echo read (used for password prompts); we honour it by
187
+ * reading through the shared input layer with echo suppressed on a TTY
188
+ * (and a plain read otherwise — non-interactive input still consumes the
189
+ * line, matching upstream).
190
+ *
191
+ * Args after the optional `-` flag are `[TEXT] varname`: if only one
192
+ * remains it is the variable name and no prompt prefix is shown; if two,
193
+ * the first is the prompt and the second the variable.
194
+ */
195
+ export const cmdPrompt = {
196
+ name: 'prompt',
197
+ helpKey: 'prompt',
198
+ run: async (ctx) => {
199
+ const args = [];
200
+ for (;;) {
201
+ const a = ctx.nextArg('normal');
202
+ if (a === null)
203
+ break;
204
+ args.push(a);
205
+ }
206
+ // A leading `-` selects the no-echo (password) read path.
207
+ let echo = true;
208
+ if (args.length > 0 && args[0] === '-') {
209
+ echo = false;
210
+ args.shift();
211
+ }
212
+ if (args.length === 0) {
213
+ writeErr(`\\${ctx.cmdName}: missing required argument\n`);
214
+ return { status: 'error' };
215
+ }
216
+ let promptText = '';
217
+ let varname;
218
+ if (args.length === 1) {
219
+ varname = args[0];
220
+ }
221
+ else {
222
+ promptText = args[0];
223
+ varname = args[1];
224
+ }
225
+ const line = await readLine(promptText, { echo });
226
+ if (!ctx.settings.vars.set(varname, line)) {
227
+ writeErr(`\\${ctx.cmdName}: invalid variable name "${varname}"\n`);
228
+ return { status: 'error' };
229
+ }
230
+ return { status: 'ok' };
231
+ },
232
+ };
233
+ /**
234
+ * `\set [varname [value...]]`
235
+ *
236
+ * - No args → list all variables (sorted, `name = 'value'` per line) to
237
+ * stdout. Upstream uses single-quotes around the value.
238
+ * - One arg → set the variable to the empty string.
239
+ * - More args → join the rest with a single space and set the variable.
240
+ *
241
+ * Diagnostics mirror upstream `exec_command_set` in `src/bin/psql/command.c`:
242
+ *
243
+ * - Names containing characters outside `[A-Za-z_][A-Za-z0-9_]*` produce
244
+ * `invalid variable name: "<name>"` (prefixed with `psql: `).
245
+ * - Per-variable hook rejections (AUTOCOMMIT / FETCH_COUNT /
246
+ * ON_ERROR_ROLLBACK / VERBOSITY / etc.) carry the hook's message
247
+ * verbatim; we add only the `psql: ` prefix.
248
+ * - Hook vetoes with no message fall back to a generic line. This
249
+ * should not happen in practice — every registered hook either
250
+ * accepts or returns a wording string.
251
+ */
252
+ export const cmdSet = {
253
+ name: 'set',
254
+ helpKey: 'set',
255
+ run: (ctx) => {
256
+ const name = ctx.nextArg('normal');
257
+ if (name === null) {
258
+ // List all vars sorted by name.
259
+ const entries = [...ctx.settings.vars.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
260
+ for (const [k, v] of entries) {
261
+ writeOut(`${k} = '${v}'\n`);
262
+ }
263
+ return Promise.resolve({ status: 'ok' });
264
+ }
265
+ const values = [];
266
+ for (;;) {
267
+ const a = ctx.nextArg('normal');
268
+ if (a === null)
269
+ break;
270
+ values.push(a);
271
+ }
272
+ const value = values.join('');
273
+ const result = ctx.settings.vars.trySet(name, value);
274
+ if (!result.ok) {
275
+ const prefix = psqlErrorPrefix(ctx.settings);
276
+ if (result.reason === 'invalid-name') {
277
+ writeErr(`${prefix}invalid variable name: "${name}"\n`);
278
+ }
279
+ else if (result.error !== undefined) {
280
+ // Hook supplied its own wording — emit verbatim, prefixed with
281
+ // `psql: `. The message intentionally does NOT carry a severity
282
+ // (`error:` / `ERROR:`) because upstream's per-variable hooks
283
+ // also emit just `psql: <msg>` (see `bool_substitute_hook` etc.).
284
+ writeErr(`${prefix}${result.error}\n`);
285
+ }
286
+ else {
287
+ // Hook returned `false` without a message — fall back to a
288
+ // generic line so callers still see something. None of the
289
+ // built-in hooks take this path, but third-party callers might.
290
+ writeErr(`${prefix}error while setting variable "${name}"\n`);
291
+ }
292
+ return Promise.resolve({ status: 'error' });
293
+ }
294
+ return Promise.resolve({ status: 'ok' });
295
+ },
296
+ };
297
+ /**
298
+ * `\r` / `\reset` — discard the accumulated query buffer.
299
+ *
300
+ * Mirrors upstream `exec_command_reset`:
301
+ *
302
+ * resetPQExpBuffer(query_buf);
303
+ * psql_scan_reset(scan_state);
304
+ * if (!pset.quiet)
305
+ * puts(_("Query buffer reset (cleared)."));
306
+ *
307
+ * We model the buffer + scanner reset via `status: 'reset-buf'`; the
308
+ * mainloop wipes `queryBuf` and re-initialises `scanState` when it sees
309
+ * this. The diagnostic is gated on the `quiet` setting so `psql -q` (and
310
+ * the regress harness, which passes `--quiet`) produces no output.
311
+ */
312
+ export const cmdReset = {
313
+ name: 'r',
314
+ aliases: ['reset'],
315
+ helpKey: 'r',
316
+ run: (ctx) => {
317
+ if (!ctx.settings.quiet) {
318
+ writeOut('Query buffer reset (cleared).\n');
319
+ }
320
+ return Promise.resolve({ status: 'reset-buf', newBuf: '' });
321
+ },
322
+ };
323
+ /** `\unset varname` — unset a psql variable. */
324
+ export const cmdUnset = {
325
+ name: 'unset',
326
+ helpKey: 'unset',
327
+ run: (ctx) => {
328
+ const name = ctx.nextArg('normal');
329
+ if (name === null) {
330
+ writeErr(`\\${ctx.cmdName}: missing required argument\n`);
331
+ return Promise.resolve({ status: 'error' });
332
+ }
333
+ ctx.settings.vars.unset(name);
334
+ return Promise.resolve({ status: 'ok' });
335
+ },
336
+ };
337
+ export const cmdGetenv = {
338
+ name: 'getenv',
339
+ helpKey: 'getenv',
340
+ run: (ctx) => {
341
+ const varname = ctx.nextArg('normal');
342
+ const envname = ctx.nextArg('normal');
343
+ if (varname === null || envname === null) {
344
+ writeErr(`\\${ctx.cmdName}: missing required argument\n`);
345
+ return Promise.resolve({ status: 'error' });
346
+ }
347
+ const value = process.env[envname];
348
+ if (value === undefined) {
349
+ return Promise.resolve({ status: 'ok' });
350
+ }
351
+ if (!ctx.settings.vars.set(varname, value)) {
352
+ writeErr(`\\${ctx.cmdName}: invalid variable name "${varname}"\n`);
353
+ return Promise.resolve({ status: 'error' });
354
+ }
355
+ return Promise.resolve({ status: 'ok' });
356
+ },
357
+ };
358
+ /**
359
+ * `\setenv envvar [value]`
360
+ *
361
+ * Set `process.env[envvar] = value`; with no value, delete it. Upstream
362
+ * rejects names containing `=`.
363
+ */
364
+ export const cmdSetenv = {
365
+ name: 'setenv',
366
+ helpKey: 'setenv',
367
+ run: (ctx) => {
368
+ const envname = ctx.nextArg('normal');
369
+ if (envname === null) {
370
+ writeErr(`\\${ctx.cmdName}: missing required argument\n`);
371
+ return Promise.resolve({ status: 'error' });
372
+ }
373
+ if (envname.includes('=')) {
374
+ writeErr(`\\${ctx.cmdName}: environment variable name must not contain "="\n`);
375
+ return Promise.resolve({ status: 'error' });
376
+ }
377
+ // Upstream `exec_command_setenv` reads BOTH the name AND the value with
378
+ // OT_NORMAL — `:VAR` substitution applies to the value so
379
+ // `\setenv FOO :BAR` propagates the psql-variable value into the env.
380
+ // (Earlier 'no-vars' was a misread; vanilla psql expands inside the
381
+ // value.) The mainloop context maintains a per-mode cursor, so using
382
+ // a single mode for both calls also keeps positional reads in sync —
383
+ // each cursor advances exactly once per call.
384
+ const value = ctx.nextArg('normal');
385
+ if (value === null) {
386
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
387
+ delete process.env[envname];
388
+ }
389
+ else {
390
+ process.env[envname] = value;
391
+ }
392
+ return Promise.resolve({ status: 'ok' });
393
+ },
394
+ };
395
+ /**
396
+ * Build the `psql:` diagnostic prefix that upstream `pg_log_pre_callback`
397
+ * prepends to error lines, but ONLY when reading from a script file. Mirrors:
398
+ *
399
+ * if (cur_cmd_source == QUERY_FROM_FILE)
400
+ * fprintf(stderr, "psql:%s:%d: ", cur_cmd_filename, cur_cmd_lineno);
401
+ *
402
+ * - `curCmdSource === 'file'` (running under `-f FILE`, `\i FILE`,
403
+ * `\ir FILE`, or `.psqlrc`): `psql:<inputfile>:<lineno>: `.
404
+ * - Stdin pipe, `-c "..."`, interactive REPL: empty string — vanilla
405
+ * `psql --no-psqlrc -X` reading SQL from stdin emits NO prefix on
406
+ * either `\set` validation errors or server `ERROR:` lines.
407
+ *
408
+ * Returned string ends in a trailing space when non-empty so callers can
409
+ * concatenate the severity directly (`prefix + 'ERROR: msg'`).
410
+ */
411
+ export const psqlErrorPrefix = (settings, lineNumber) => {
412
+ if (settings.curCmdSource === 'file' && settings.inputfile) {
413
+ const lineSuffix = lineNumber !== undefined ? String(lineNumber) : '';
414
+ return `psql:${settings.inputfile}:${lineSuffix}: `;
415
+ }
416
+ return '';
417
+ };
418
+ /**
419
+ * Walk past leading whitespace + `--` line comments + slash-star block
420
+ * comments at the head of `sqlText`. Returns the byte index of the first
421
+ * "real" content character. Used by `renderLineAndCaret` to align the
422
+ * `LINE N:` counter with upstream psql — vanilla strips these from the
423
+ * buffer before `PQexec` (so the server's `position` is relative to the
424
+ * trimmed buffer), but `captureLastError`/`normaliseSqlAndPosition` only
425
+ * strips whitespace. Re-stripping here closes the gap when the captured
426
+ * `sqlText` still carries leading `-- comment` lines (the common case
427
+ * for SQL that the mainloop dispatched directly via `sendQuery`,
428
+ * because that path doesn't pre-trim comments).
429
+ *
430
+ * Idempotent for already-trimmed input: if `sqlText` has no leading
431
+ * prelude we return `0`, the caller takes the existing fast-path, and
432
+ * the LINE count remains the count of newlines strictly before
433
+ * `position - 1`.
434
+ */
435
+ const skipLeadingPrelude = (sqlText) => {
436
+ let i = 0;
437
+ const n = sqlText.length;
438
+ while (i < n) {
439
+ const c = sqlText.charCodeAt(i);
440
+ if (c === 0x20 ||
441
+ c === 0x09 ||
442
+ c === 0x0a ||
443
+ c === 0x0d ||
444
+ c === 0x0c ||
445
+ c === 0x0b) {
446
+ i++;
447
+ continue;
448
+ }
449
+ if (c === 0x2d && sqlText.charCodeAt(i + 1) === 0x2d) {
450
+ i += 2;
451
+ while (i < n && sqlText.charCodeAt(i) !== 0x0a)
452
+ i++;
453
+ continue;
454
+ }
455
+ if (c === 0x2f && sqlText.charCodeAt(i + 1) === 0x2a) {
456
+ i += 2;
457
+ let depth = 1;
458
+ while (i < n && depth > 0) {
459
+ if (sqlText.charCodeAt(i) === 0x2f &&
460
+ sqlText.charCodeAt(i + 1) === 0x2a) {
461
+ depth++;
462
+ i += 2;
463
+ }
464
+ else if (sqlText.charCodeAt(i) === 0x2a &&
465
+ sqlText.charCodeAt(i + 1) === 0x2f) {
466
+ depth--;
467
+ i += 2;
468
+ }
469
+ else {
470
+ i++;
471
+ }
472
+ }
473
+ continue;
474
+ }
475
+ break;
476
+ }
477
+ return i;
478
+ };
479
+ /**
480
+ * Render the `LINE N: …` re-print plus the `^` pointer underneath the
481
+ * failing character, mirroring upstream psql's `report_error_query`
482
+ * helper. Returns `null` when we don't have enough context (no SQL text
483
+ * or no position) so the caller can skip the lines entirely.
484
+ *
485
+ * `position` is a 1-based character offset into `sqlText` (as delivered
486
+ * in the server's `P` field). We pick the LINE containing that offset
487
+ * and emit:
488
+ *
489
+ * LINE N: <that line>
490
+ * ^
491
+ *
492
+ * The caret column is aligned to the offset within the picked line so
493
+ * it points at the failing token. Trailing newlines on the picked line
494
+ * are stripped so the `$` end-anchor in upstream's regex still matches.
495
+ *
496
+ * Leading whitespace + comments are skipped before computing the LINE
497
+ * number so the count starts at the first content line — vanilla
498
+ * advances past these before `PQexec`, so the server's `position` is
499
+ * 1-based relative to a trimmed buffer; without the same skip here we'd
500
+ * count newlines that vanilla never sent.
501
+ *
502
+ * Trailing-whitespace fix-up: callers in `cmd_io.ts` strip a `\g`-style
503
+ * buffer's trailing whitespace before handing the SQL to `db.execSimple`
504
+ * / `db.query`, so the server's `position` is relative to the trimmed
505
+ * SQL while `sqlText` (used for the LINE echo) still carries the
506
+ * trailing space(s). When `position` lands on trailing whitespace of
507
+ * the LINE we picked — i.e., past `lineText.trimEnd().length` — that's
508
+ * the "syntax error at end of input" case: vanilla sends the trailing
509
+ * whitespace verbatim and the server reports a position one past the
510
+ * full LINE length. Snap the caret to the end of `lineText` so our
511
+ * output matches vanilla's `^` column. PostgreSQL's scanner never
512
+ * emits positions pointing AT whitespace tokens (they're not lexed as
513
+ * tokens), so the only realistic source of an "in trailing
514
+ * whitespace" position is this trim-on-send delta.
515
+ *
516
+ * Exported so the per-statement error renderer in `core/common.ts` can
517
+ * share the helper with `\errverbose`.
518
+ */
519
+ export const renderLineAndCaret = (sqlText, position) => {
520
+ if (!sqlText || !position)
521
+ return null;
522
+ const pos = parseInt(position, 10);
523
+ if (!Number.isFinite(pos) || pos <= 0)
524
+ return null;
525
+ // Advance past leading WS + comments so the LINE count starts at the
526
+ // first content line. The server's position is into the on-the-wire
527
+ // bytes — typically already past these, so rebasing keeps it inside
528
+ // the content range; if the rebased position would underflow we drop
529
+ // the LINE/caret block rather than mis-pointing.
530
+ const skip = skipLeadingPrelude(sqlText);
531
+ const trimmed = skip === 0 ? sqlText : sqlText.slice(skip);
532
+ const rebasedPos = pos - skip;
533
+ if (rebasedPos <= 0)
534
+ return null;
535
+ // The server's offset is 1-based and points at the failing character.
536
+ const idx = Math.min(rebasedPos - 1, trimmed.length);
537
+ // Find the line containing `idx`.
538
+ let lineStart = trimmed.lastIndexOf('\n', idx - 1);
539
+ lineStart = lineStart === -1 ? 0 : lineStart + 1;
540
+ let lineEnd = trimmed.indexOf('\n', lineStart);
541
+ if (lineEnd === -1)
542
+ lineEnd = trimmed.length;
543
+ const lineText = trimmed.slice(lineStart, lineEnd);
544
+ // Line number for the `LINE N:` prefix — 1-based.
545
+ const before = trimmed.slice(0, lineStart);
546
+ const lineNumber = (before.match(/\n/gu)?.length ?? 0) + 1;
547
+ // Column inside the picked line (0-based) where the `^` goes. Tabs
548
+ // upstream are expanded to a fixed width; we approximate with a
549
+ // single space so the pointer at least lands in the right ballpark.
550
+ let col = idx - lineStart;
551
+ // Snap past trailing whitespace when the position lands inside it —
552
+ // see the function header for the rationale (trim-on-send delta).
553
+ const lineTrimEndLen = lineText.replace(/[ \t\f\v]+$/u, '').length;
554
+ if (col >= lineTrimEndLen && col < lineText.length) {
555
+ col = lineText.length;
556
+ }
557
+ const caretIndent = ' '.repeat(Math.max(0, col));
558
+ const prefix = `LINE ${String(lineNumber)}: `;
559
+ return {
560
+ line: `${prefix}${lineText}`,
561
+ caret: `${' '.repeat(prefix.length)}${caretIndent}^`,
562
+ };
563
+ };
564
+ /**
565
+ * Render an ErrorResponse-shaped payload as the layered, verbosity-aware
566
+ * report that upstream psql emits to stderr after a failed statement
567
+ * (`PSQLExec` / `ProcessResult` in `src/bin/psql/common.c`).
568
+ *
569
+ * Returned array contains one element per logical line, without trailing
570
+ * newlines — callers join with `\n` and write to their stream.
571
+ *
572
+ * Verbosity / SHOW_CONTEXT semantics, mirrored from upstream:
573
+ *
574
+ * - `terse`: only the severity line (`<sev>: <msg>`) is emitted.
575
+ *
576
+ * - `default`: severity + message, plus `LINE N` / caret, DETAIL, HINT,
577
+ * STATEMENT (we omit STATEMENT — we never echo the query verbatim).
578
+ * CONTEXT and LOCATION are suppressed unless `SHOW_CONTEXT='always'`.
579
+ *
580
+ * - `verbose`: adds the SQLSTATE prefix on the severity line, and
581
+ * CONTEXT plus LOCATION are unconditionally included when present.
582
+ *
583
+ * - `sqlstate`: prepend the SQLSTATE on the severity line (same as
584
+ * `verbose`'s first line), but suppress LINE/DETAIL/HINT/CONTEXT/
585
+ * LOCATION. Matches the upstream "just give me the code" flavour.
586
+ *
587
+ * Empty server fields are skipped silently. The `LINE` / `^` pair only
588
+ * appears when we have both originating SQL text and a 1-based position
589
+ * pointing inside it.
590
+ */
591
+ export const formatErrorReport = (e, verbosity = 'default', showContext = 'errors') => {
592
+ const severity = e.severity ?? 'ERROR';
593
+ const sqlstate = e.code ?? e.sqlstate ?? 'XX000';
594
+ const message = e.message ?? '';
595
+ const out = [];
596
+ // `sqlstate` mode is the upstream "just give me the code" flavour:
597
+ // emit `<severity>: <sqlstate>` with NO message body. `verbose` mode
598
+ // adds the SQLSTATE prefix and keeps the message + LINE/DETAIL/HINT
599
+ // layers below. Default/terse omit the SQLSTATE entirely.
600
+ //
601
+ // Reference: upstream `pg_log_pre_callback` / `PQresultErrorMessage`
602
+ // with `verbosity = PQERRORS_SQLSTATE`, which formats just
603
+ // `severity: sqlstate\n` and stops.
604
+ if (verbosity === 'sqlstate') {
605
+ out.push(`${severity}: ${sqlstate}`);
606
+ return out;
607
+ }
608
+ if (verbosity === 'verbose') {
609
+ out.push(`${severity}: ${sqlstate}: ${message}`);
610
+ }
611
+ else if (verbosity === 'terse') {
612
+ // Terse suppresses LINE/caret/DETAIL/HINT/CONTEXT, but it merges the
613
+ // server's `position` into the severity line as `at character N` —
614
+ // matches libpq's `pqGetErrorNotice3` with `PQERRORS_TERSE` (and
615
+ // vanilla psql in the regress fixture). Only fires when position is a
616
+ // positive integer; the LINE/caret block below would have shown the
617
+ // same anchor for default verbosity.
618
+ const pos = e.position ? Number.parseInt(e.position, 10) : NaN;
619
+ if (Number.isFinite(pos) && pos > 0) {
620
+ out.push(`${severity}: ${message} at character ${String(pos)}`);
621
+ }
622
+ else {
623
+ out.push(`${severity}: ${message}`);
624
+ }
625
+ return out;
626
+ }
627
+ else {
628
+ out.push(`${severity}: ${message}`);
629
+ }
630
+ const lineCaret = renderLineAndCaret(e.sqlText, e.position);
631
+ if (lineCaret) {
632
+ out.push(lineCaret.line);
633
+ out.push(lineCaret.caret);
634
+ }
635
+ if (e.detail)
636
+ out.push(`DETAIL: ${e.detail}`);
637
+ if (e.hint)
638
+ out.push(`HINT: ${e.hint}`);
639
+ // CONTEXT under default verbosity follows SHOW_CONTEXT: 'never' / 'errors'
640
+ // (the default — show on errors) / 'always'. We treat every call into the
641
+ // formatter as an error report, so 'errors' and 'always' both include
642
+ // CONTEXT, while 'never' suppresses it. Verbose verbosity unconditionally
643
+ // includes CONTEXT.
644
+ const includeContext = verbosity === 'verbose' || showContext !== 'never';
645
+ if (includeContext && e.where) {
646
+ out.push(`CONTEXT: ${e.where}`);
647
+ }
648
+ if (verbosity === 'verbose' && (e.routine || e.file || e.line)) {
649
+ const location = (e.routine ?? '') + (e.file ? `, ${e.file}:${e.line ?? ''}` : '');
650
+ out.push(`LOCATION: ${location}`);
651
+ }
652
+ return out;
653
+ };
654
+ /**
655
+ * `\errverbose` — print the last error in verbose form. We rely on the
656
+ * mainloop to have stored `settings.lastErrorResult`; this command only
657
+ * formats and prints. Without a saved error, upstream emits "There is no
658
+ * previous error."
659
+ *
660
+ * Verbose output (PG 18 form):
661
+ *
662
+ * ERROR: <sqlstate>: <message>
663
+ * LINE N: <originating line of SQL>
664
+ * ^
665
+ * DETAIL: <detail>
666
+ * HINT: <hint>
667
+ * CONTEXT: <where>
668
+ * LOCATION: <routine>, <file>:<line>
669
+ *
670
+ * Empty fields are omitted. The `LINE` / `^` pair is only emitted when
671
+ * we have both the originating SQL text and a server-provided position.
672
+ */
673
+ export const cmdErrverbose = {
674
+ name: 'errverbose',
675
+ helpKey: 'errverbose',
676
+ run: (ctx) => {
677
+ const e = ctx.settings.lastErrorResult;
678
+ if (!e || (!e.message && !e.sqlstate && !e.code)) {
679
+ // Upstream `exec_command_errverbose` writes the "no previous error"
680
+ // notice to stdout (via `printf`); only the verbose re-render goes to
681
+ // stderr (via `pg_log_error`).
682
+ writeOut('There is no previous error.\n');
683
+ return Promise.resolve({ status: 'ok' });
684
+ }
685
+ // `\errverbose` always emits the full verbose form regardless of the
686
+ // currently active VERBOSITY setting. Output is prefixed with the same
687
+ // `psql:[<file>:<n>]:` tag upstream's `pg_log_pre_callback` adds — only
688
+ // on the leading severity line; subsequent layers (LINE / caret / DETAIL
689
+ // / HINT / LOCATION) stay unprefixed to match libpq's `PQresultErrorMessage`.
690
+ const lines = formatErrorReport(e, 'verbose', 'always');
691
+ const prefix = psqlErrorPrefix(ctx.settings);
692
+ const prefixed = [prefix + lines[0], ...lines.slice(1)];
693
+ writeErr(prefixed.join('\n') + '\n');
694
+ return Promise.resolve({ status: 'ok' });
695
+ },
696
+ };
697
+ /**
698
+ * `\timing [on|off]` — set `settings.timing`. With no arg the value is
699
+ * flipped. Prints the new state to stdout. `toggle` is NOT a valid value —
700
+ * upstream errors "Boolean expected" (review: minor divergences).
701
+ */
702
+ export const cmdTiming = {
703
+ name: 'timing',
704
+ helpKey: 'timing',
705
+ run: (ctx) => {
706
+ const arg = ctx.nextArg('normal');
707
+ let next;
708
+ if (arg === null) {
709
+ next = !ctx.settings.timing;
710
+ }
711
+ else {
712
+ const parsed = parseBool(arg);
713
+ if (parsed === null) {
714
+ writeErr(`\\${ctx.cmdName}: unrecognized value "${arg}" for "\\timing": Boolean expected\n`);
715
+ return Promise.resolve({ status: 'error' });
716
+ }
717
+ next = parsed;
718
+ }
719
+ ctx.settings.timing = next;
720
+ writeOut(`Timing is ${next ? 'on' : 'off'}.\n`);
721
+ return Promise.resolve({ status: 'ok' });
722
+ },
723
+ };
724
+ /**
725
+ * Static text emitted by `\copyright`. Mirrors upstream psql's
726
+ * `exec_command_copyright()` literal in `src/bin/psql/command.c`.
727
+ */
728
+ const COPYRIGHT_TEXT = `PostgreSQL Database Management System
729
+ (formerly known as Postgres, then as Postgres95)
730
+
731
+ Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
732
+
733
+ Portions Copyright (c) 1994, The Regents of the University of California
734
+
735
+ Permission to use, copy, modify, and distribute this software and its
736
+ documentation for any purpose, without fee, and without a written agreement
737
+ is hereby granted, provided that the above copyright notice and this
738
+ paragraph and the following two paragraphs appear in all copies.
739
+
740
+ IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR
741
+ DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING
742
+ LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION,
743
+ EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
744
+ SUCH DAMAGE.
745
+
746
+ THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
747
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
748
+ AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS
749
+ ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO
750
+ PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
751
+ `;
752
+ /**
753
+ * neonctl-specific notice appended after the upstream PostgreSQL block.
754
+ * This psql is a pure-TypeScript reimplementation embedded in neonctl, not
755
+ * upstream psql — so we attribute it accordingly. No license claim is made
756
+ * here on purpose (see the project's LICENSE file for terms).
757
+ */
758
+ const NEON_NOTICE = `
759
+ This is an embedded psql reimplementation that ships with neonctl, the
760
+ command-line interface for Neon (https://neon.tech). Neon is part of
761
+ Databricks (https://www.databricks.com).
762
+
763
+ It is an independent reimplementation of psql and is not affiliated with
764
+ or endorsed by the PostgreSQL Global Development Group. See the neonctl
765
+ LICENSE file for distribution terms.
766
+ `;
767
+ /**
768
+ * `\copyright` — print the PostgreSQL copyright / license notice, followed
769
+ * by a neonctl + Neon/Databricks attribution block. Takes no arguments.
770
+ * The upstream block is preserved verbatim so the conformance regex
771
+ * `/Copyright/` (from upstream `001_basic.pl` line 75) is satisfied; the
772
+ * Neon notice is appended after it.
773
+ */
774
+ export const cmdCopyright = {
775
+ name: 'copyright',
776
+ helpKey: 'copyright',
777
+ run: () => {
778
+ writeOut(COPYRIGHT_TEXT + NEON_NOTICE);
779
+ return Promise.resolve({ status: 'ok' });
780
+ },
781
+ };
782
+ /**
783
+ * Terminal width used to lay out `\h` / `\help` topic lists. Upstream
784
+ * uses `pset.popt.topt.envColumns` falling back to `ioctl(TIOCGWINSZ)`;
785
+ * we read `process.stdout.columns` (Node populates this for TTYs) and
786
+ * default to 80 if absent (non-TTY, piped output, etc.).
787
+ */
788
+ const screenWidth = () => {
789
+ const cols = process.stdout.columns;
790
+ return typeof cols === 'number' && cols > 0 ? cols : 80;
791
+ };
792
+ /**
793
+ * `\h [TOPIC]` (alias `\help`) — show SQL command help.
794
+ *
795
+ * Delegates to {@link helpSQL} in `core/help.ts`, passing the remainder
796
+ * of the line as the topic. With no topic, prints the "Available help:"
797
+ * overview; with a topic, prints the matching synopsis or a list of
798
+ * matches. Mirrors upstream `exec_command_help` in `command.c`.
799
+ */
800
+ export const cmdHelpSQL = {
801
+ name: 'h',
802
+ aliases: ['help'],
803
+ helpKey: 'h',
804
+ run: (ctx) => {
805
+ // Upstream consumes the rest of the line in `OT_WHOLE_LINE` mode so
806
+ // multi-word topics like "CREATE TABLE" come through intact.
807
+ const topic = ctx.restOfLine();
808
+ helpSQL(process.stdout, topic.length === 0 ? null : topic, screenWidth());
809
+ return Promise.resolve({ status: 'ok' });
810
+ },
811
+ };
812
+ /**
813
+ * `\?` — show help for the backslash commands.
814
+ *
815
+ * Delegates to {@link slashUsage} in `core/help.ts`. We pass the output
816
+ * stream (`process.stdout`) and request the pager only when that stream is
817
+ * an interactive TTY — `slashUsage`/`emitHelp` re-check interactivity, but
818
+ * gating the request here keeps the non-interactive path (scripts, piped
819
+ * output, the regress harness) writing straight to stdout with no pager.
820
+ *
821
+ * Upstream `exec_command_help` reads `[commands|options|variables]`; we
822
+ * mirror the default (backslash commands) form, which is the only variant
823
+ * `\?` reaches without an argument. The remainder of the line is consumed
824
+ * so a stray topic doesn't leak into the next command.
825
+ */
826
+ export const cmdSlashHelp = {
827
+ name: '?',
828
+ argMode: 'whole-line',
829
+ helpKey: '?',
830
+ run: (ctx) => {
831
+ // Consume the rest of the line (`\? options`, `\? variables`) so the
832
+ // cursor doesn't strand trailing text; we only render the command help.
833
+ ctx.restOfLine();
834
+ const out = process.stdout;
835
+ const pager = Boolean(out.isTTY);
836
+ slashUsage(out, pager);
837
+ return Promise.resolve({ status: 'ok' });
838
+ },
839
+ };
840
+ /**
841
+ * Resolve the editor command psql would launch for `\e` / `\ef` / `\ev`,
842
+ * mirroring upstream `editFile` / `get_alternate_expansion`:
843
+ *
844
+ * $PSQL_EDITOR || $EDITOR || $VISUAL || platform default
845
+ *
846
+ * The platform default is `notepad.exe` on Windows and `vi` elsewhere,
847
+ * matching upstream's `DEFAULT_EDITOR`.
848
+ */
849
+ export const resolveEditor = (env = process.env) => {
850
+ const explicit = env.PSQL_EDITOR ?? env.EDITOR ?? env.VISUAL;
851
+ if (explicit !== undefined && explicit.length > 0)
852
+ return explicit;
853
+ return process.platform === 'win32' ? 'notepad.exe' : 'vi';
854
+ };
855
+ /**
856
+ * `\e` / `\edit [FILE] [LINE]` — edit the current query buffer (or a file)
857
+ * in the user's editor, then load the edited text back into the query
858
+ * buffer.
859
+ *
860
+ * This port implements the common no-FILE form: dump the current query
861
+ * buffer to a temp file, spawn the editor on it inheriting stdio (upstream
862
+ * `do_edit` → `editFile`), and on a clean exit read the file back and
863
+ * return it as the new query buffer via `status: 'reset-buf'`. Upstream
864
+ * strips a single trailing newline the editor may add; we do the same so
865
+ * round-tripping an unchanged buffer is a no-op.
866
+ *
867
+ * Editor selection follows {@link resolveEditor}. The spawn uses
868
+ * `spawnSync(..., { stdio: 'inherit' })` so the editor owns the terminal.
869
+ * If the editor exits non-zero (or fails to spawn) we leave the buffer
870
+ * untouched and report an error, matching upstream's behaviour of not
871
+ * importing a failed edit.
872
+ *
873
+ * FILE / LINE arguments are accepted but the buffer is still seeded from
874
+ * the current query buffer; a future WP can layer file-backed editing
875
+ * (`\e file`) and `\ef`/`\ev` on top.
876
+ */
877
+ export const cmdEdit = {
878
+ name: 'e',
879
+ aliases: ['edit'],
880
+ argMode: 'whole-line',
881
+ helpKey: 'e',
882
+ run: (ctx) => {
883
+ // We don't yet support `\e FILE`; consume the args so they don't strand.
884
+ ctx.restOfLine();
885
+ const editor = resolveEditor();
886
+ // psql seeds the temp file with the current query buffer. A trailing
887
+ // newline keeps editors that expect newline-terminated files happy.
888
+ const seed = ctx.queryBuf.length > 0 && !ctx.queryBuf.endsWith('\n')
889
+ ? ctx.queryBuf + '\n'
890
+ : ctx.queryBuf;
891
+ let dir = null;
892
+ try {
893
+ dir = mkdtempSync(join(tmpdir(), 'psql.edit.'));
894
+ const file = join(dir, 'edit.sql');
895
+ writeFileSync(file, seed, 'utf8');
896
+ const result = spawnSync(editor, [file], { stdio: 'inherit' });
897
+ if (result.error || (result.status !== null && result.status !== 0)) {
898
+ const why = result.error
899
+ ? result.error.message
900
+ : `editor exited with status ${String(result.status)}`;
901
+ writeErr(`\\${ctx.cmdName}: ${why}\n`);
902
+ return Promise.resolve({ status: 'error' });
903
+ }
904
+ let edited = readFileSync(file, 'utf8');
905
+ // Upstream drops a single trailing newline the editor may have added
906
+ // so an unchanged round-trip restores the original buffer exactly.
907
+ if (edited.endsWith('\n'))
908
+ edited = edited.slice(0, -1);
909
+ return Promise.resolve({ status: 'reset-buf', newBuf: edited });
910
+ }
911
+ catch (err) {
912
+ const msg = err instanceof Error ? err.message : String(err);
913
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
914
+ return Promise.resolve({ status: 'error' });
915
+ }
916
+ finally {
917
+ if (dir) {
918
+ try {
919
+ rmSync(dir, { recursive: true, force: true });
920
+ }
921
+ catch {
922
+ // Temp-dir cleanup is best-effort; a leftover dir is harmless.
923
+ }
924
+ }
925
+ }
926
+ },
927
+ };
928
+ /**
929
+ * `\s [FILENAME]` — print the command-line history, or save it to FILENAME.
930
+ *
931
+ * Mirrors upstream `exec_command_s` / `printHistory`:
932
+ *
933
+ * - No argument: write the in-memory history (one entry per line) to
934
+ * stdout. Multi-line entries are printed verbatim (with their embedded
935
+ * newlines), matching readline's `\s` dump.
936
+ * - FILENAME given: write the same dump to that file. On success, and
937
+ * unless `\set QUIET` is in effect, print `Wrote history to file
938
+ * "<file>".` to stdout. On failure, emit the OS error to stderr and
939
+ * return an error.
940
+ *
941
+ * The history source is {@link getHistory}, the session's in-memory list
942
+ * populated as each line is submitted (see `io/history.ts`).
943
+ */
944
+ export const cmdS = {
945
+ name: 's',
946
+ helpKey: 's',
947
+ run: (ctx) => {
948
+ const fname = ctx.nextArg('normal');
949
+ const entries = getHistory();
950
+ // Each entry is one logical command; readline's `\s` prints them one
951
+ // per line, so a trailing newline per entry reproduces that layout.
952
+ const body = entries.map((e) => e + '\n').join('');
953
+ if (fname === null || fname.length === 0) {
954
+ writeOut(body);
955
+ return Promise.resolve({ status: 'ok' });
956
+ }
957
+ try {
958
+ writeFileSync(fname, body, 'utf8');
959
+ }
960
+ catch (err) {
961
+ const msg = err instanceof Error ? err.message : String(err);
962
+ writeErr(`\\${ctx.cmdName}: ${msg}\n`);
963
+ return Promise.resolve({ status: 'error' });
964
+ }
965
+ if (!ctx.settings.quiet) {
966
+ writeOut(`Wrote history to file "${fname}".\n`);
967
+ }
968
+ return Promise.resolve({ status: 'ok' });
969
+ },
970
+ };