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.
- package/README.md +84 -0
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/connection_string.js +9 -1
- package/commands/functions.js +277 -0
- package/commands/index.js +4 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +6 -1
- package/functions_api.js +44 -0
- package/package.json +15 -5
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/esbuild.js +147 -0
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/checkout.test.js +0 -170
- package/commands/connection_string.test.js +0 -196
- package/commands/data_api.test.js +0 -169
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/link.test.js +0 -381
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/psql.test.js +0 -49
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/context.test.js +0 -119
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* psql history file I/O.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of the history-handling portion of PostgreSQL's
|
|
5
|
+
* `src/bin/psql/input.c`. Implements the read/append/truncate operations the
|
|
6
|
+
* REPL drives plus the small env-resolution helpers (`PSQL_HISTORY`,
|
|
7
|
+
* `HISTSIZE`).
|
|
8
|
+
*
|
|
9
|
+
* On-disk format follows GNU readline's history file:
|
|
10
|
+
*
|
|
11
|
+
* - One entry per line, UTF-8.
|
|
12
|
+
* - Multi-line entries are encoded with the literal escape sequences
|
|
13
|
+
* `\n` (backslash + n), `\r`, and `\\`. We decode them back to real
|
|
14
|
+
* newlines / carriage returns / backslashes on load and re-encode on
|
|
15
|
+
* write. Upstream psql uses an in-memory NL_IN_HISTORY (0x01) marker
|
|
16
|
+
* that libreadline then translates to the same on-disk form; we skip
|
|
17
|
+
* the intermediate marker and operate on real strings throughout.
|
|
18
|
+
* - Lines whose first character is `#` are treated as timestamp / comment
|
|
19
|
+
* markers (libreadline writes them when `history_write_timestamps` is
|
|
20
|
+
* set) and silently skipped on load. psql itself never writes them.
|
|
21
|
+
*
|
|
22
|
+
* Intentional deviations from upstream:
|
|
23
|
+
*
|
|
24
|
+
* - Append-only writes use `fs.promises.appendFile`, which opens the file
|
|
25
|
+
* with `O_APPEND` on POSIX so concurrent appends from multiple psql
|
|
26
|
+
* instances do not interleave within a single write call. On Windows
|
|
27
|
+
* there is a small race window we accept; the alternative (an external
|
|
28
|
+
* lock file) is not worth the dependency churn.
|
|
29
|
+
* - `truncateHistory` writes the trimmed history to a sibling temp file
|
|
30
|
+
* and `rename`s it into place. POSIX `rename(2)` is atomic w.r.t. other
|
|
31
|
+
* processes observing the path; Windows is best-effort.
|
|
32
|
+
* - HISTCONTROL comparisons run on the raw (decoded) entry — this matches
|
|
33
|
+
* bash/readline behaviour where the user's literal input is what gets
|
|
34
|
+
* deduped, not the escaped on-disk form.
|
|
35
|
+
*/
|
|
36
|
+
import { promises as fs } from 'node:fs';
|
|
37
|
+
import * as os from 'node:os';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import { randomUUID } from 'node:crypto';
|
|
40
|
+
/** psql's compiled-in default for HISTSIZE (see `src/bin/psql/settings.h`). */
|
|
41
|
+
const DEFAULT_HISTSIZE = 500;
|
|
42
|
+
/**
|
|
43
|
+
* In-memory command-line history for the current session, oldest first.
|
|
44
|
+
*
|
|
45
|
+
* Upstream psql delegates history to GNU readline, whose `history_list()`
|
|
46
|
+
* backs the `\s` meta-command (`exec_command_s` in `command.c` prints the
|
|
47
|
+
* live in-memory history, NOT the on-disk file). We keep our own
|
|
48
|
+
* process-local mirror here, populated by {@link recordHistory} from the
|
|
49
|
+
* same funnel the REPL uses to persist a submitted line ({@link
|
|
50
|
+
* appendHistory}). `\s` reads it via {@link getHistory}.
|
|
51
|
+
*
|
|
52
|
+
* Module-level state matches the single-session lifetime of a psql process;
|
|
53
|
+
* tests reset it with {@link clearHistory}.
|
|
54
|
+
*/
|
|
55
|
+
let inMemoryHistory = [];
|
|
56
|
+
/**
|
|
57
|
+
* Record one submitted line in the session's in-memory history.
|
|
58
|
+
*
|
|
59
|
+
* Empty lines and an exact duplicate of the immediately preceding entry are
|
|
60
|
+
* dropped, mirroring readline's default behaviour (and {@link
|
|
61
|
+
* LineEditor.pushHistory}); HISTCONTROL filtering is applied by the caller
|
|
62
|
+
* ({@link appendHistory}) before this is reached.
|
|
63
|
+
*/
|
|
64
|
+
export const recordHistory = (entry) => {
|
|
65
|
+
if (entry.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
if (inMemoryHistory[inMemoryHistory.length - 1] === entry)
|
|
68
|
+
return;
|
|
69
|
+
inMemoryHistory.push(entry);
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Return the session's in-memory history (oldest first). Returns a copy so
|
|
73
|
+
* callers can't mutate the backing store.
|
|
74
|
+
*/
|
|
75
|
+
export const getHistory = () => inMemoryHistory.slice();
|
|
76
|
+
/** Reset the in-memory history. Primarily for tests and `\s`-less restarts. */
|
|
77
|
+
export const clearHistory = () => {
|
|
78
|
+
inMemoryHistory = [];
|
|
79
|
+
};
|
|
80
|
+
/** Encode a single in-memory entry to the on-disk libreadline form. */
|
|
81
|
+
const encodeEntry = (entry) => entry.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
82
|
+
/**
|
|
83
|
+
* Decode a single on-disk libreadline line back to its in-memory form.
|
|
84
|
+
*
|
|
85
|
+
* Recognised escapes: `\\` → `\`, `\n` → newline, `\r` → CR. Any other
|
|
86
|
+
* `\<x>` sequence is left as-is (matching readline's lenient decoder), so
|
|
87
|
+
* a stray backslash at the end of the file does not eat the next entry.
|
|
88
|
+
*/
|
|
89
|
+
const decodeEntry = (line) => {
|
|
90
|
+
let out = '';
|
|
91
|
+
for (let i = 0; i < line.length; i++) {
|
|
92
|
+
const c = line.charCodeAt(i);
|
|
93
|
+
if (c === 0x5c /* '\\' */ && i + 1 < line.length) {
|
|
94
|
+
const next = line[i + 1];
|
|
95
|
+
if (next === 'n') {
|
|
96
|
+
out += '\n';
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (next === 'r') {
|
|
101
|
+
out += '\r';
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (next === '\\') {
|
|
106
|
+
out += '\\';
|
|
107
|
+
i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
out += line[i];
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Return `true` if `entry` should be filtered out under `histcontrol`
|
|
117
|
+
* relative to the most recent entry already in history (`prev`).
|
|
118
|
+
*
|
|
119
|
+
* Mirrors `pg_send_history()`'s filter in `input.c`.
|
|
120
|
+
*/
|
|
121
|
+
const shouldIgnore = (entry, prev, histcontrol) => {
|
|
122
|
+
const ignoreSpace = histcontrol === 'ignorespace' || histcontrol === 'ignoreboth';
|
|
123
|
+
const ignoreDups = histcontrol === 'ignoredups' || histcontrol === 'ignoreboth';
|
|
124
|
+
if (ignoreSpace && entry.length > 0 && /^\s/.test(entry))
|
|
125
|
+
return true;
|
|
126
|
+
if (ignoreDups && prev !== undefined && prev === entry)
|
|
127
|
+
return true;
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Read a libreadline history file and return entries in chronological
|
|
132
|
+
* order (oldest first). A missing file resolves to `[]` so callers can
|
|
133
|
+
* unconditionally `loadHistory` at startup.
|
|
134
|
+
*
|
|
135
|
+
* Lines beginning with `#` are silently skipped (libreadline timestamp
|
|
136
|
+
* markers; psql itself never writes them but we tolerate them).
|
|
137
|
+
*
|
|
138
|
+
* Other I/O errors (EACCES, EISDIR, …) propagate to the caller.
|
|
139
|
+
*/
|
|
140
|
+
export const loadHistory = async (filePath) => {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err &&
|
|
147
|
+
typeof err === 'object' &&
|
|
148
|
+
'code' in err &&
|
|
149
|
+
err.code === 'ENOENT') {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
// Strip a single trailing newline so a well-formed file ending in `\n`
|
|
155
|
+
// doesn't produce a phantom empty entry. Don't strip more than one — a
|
|
156
|
+
// blank line in the middle of the file represents an entry that was
|
|
157
|
+
// literally the empty string (rare but possible), and we round-trip it.
|
|
158
|
+
if (raw.endsWith('\n'))
|
|
159
|
+
raw = raw.slice(0, -1);
|
|
160
|
+
if (raw.length === 0)
|
|
161
|
+
return [];
|
|
162
|
+
const entries = [];
|
|
163
|
+
for (const line of raw.split('\n')) {
|
|
164
|
+
if (line.startsWith('#'))
|
|
165
|
+
continue;
|
|
166
|
+
entries.push(decodeEntry(line));
|
|
167
|
+
}
|
|
168
|
+
return entries;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Append a single entry to the history file, subject to HISTCONTROL.
|
|
172
|
+
*
|
|
173
|
+
* Implementation notes:
|
|
174
|
+
*
|
|
175
|
+
* - On POSIX, `fs.appendFile` opens with `O_APPEND`, which guarantees
|
|
176
|
+
* each `write(2)` lands at the current end-of-file. A single
|
|
177
|
+
* encoded entry plus its trailing `\n` is one write, so concurrent
|
|
178
|
+
* psql instances writing to the same history file never interleave
|
|
179
|
+
* within an entry. We accept a small race on Windows.
|
|
180
|
+
* - `ignoredups` consults the *last* entry currently on disk. We read
|
|
181
|
+
* the tail of the file rather than the whole history to keep this
|
|
182
|
+
* O(1)-ish for large histories; for simplicity we read everything
|
|
183
|
+
* when ignoredups is in effect — typical history files are well
|
|
184
|
+
* under 1 MiB.
|
|
185
|
+
*/
|
|
186
|
+
export const appendHistory = async (filePath, entry, histcontrol = 'none') => {
|
|
187
|
+
if (histcontrol === 'ignoredups' ||
|
|
188
|
+
histcontrol === 'ignoreboth' ||
|
|
189
|
+
histcontrol === 'ignorespace') {
|
|
190
|
+
let prev;
|
|
191
|
+
if (histcontrol === 'ignoredups' || histcontrol === 'ignoreboth') {
|
|
192
|
+
const existing = await loadHistory(filePath);
|
|
193
|
+
prev = existing[existing.length - 1];
|
|
194
|
+
}
|
|
195
|
+
if (shouldIgnore(entry, prev, histcontrol))
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Mirror the line into the session's in-memory history (what `\s` prints)
|
|
199
|
+
// using the same funnel that persists it to disk, so the two stay in
|
|
200
|
+
// lock-step under HISTCONTROL. Recording happens AFTER the ignore check
|
|
201
|
+
// so an ignored line is absent from both.
|
|
202
|
+
recordHistory(entry);
|
|
203
|
+
await fs.appendFile(filePath, encodeEntry(entry) + '\n', 'utf8');
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Trim the history file to its last `maxLines` entries.
|
|
207
|
+
*
|
|
208
|
+
* If `maxLines <= 0` the file is removed entirely (matching psql's
|
|
209
|
+
* behaviour when HISTSIZE is set to 0). If the file already fits the
|
|
210
|
+
* cap, it's left untouched. Otherwise we write the kept tail to a
|
|
211
|
+
* sibling temp file and `rename` it into place.
|
|
212
|
+
*
|
|
213
|
+
* `rename(2)` on POSIX is atomic w.r.t. observers of the destination
|
|
214
|
+
* path; on Windows the platform call may briefly fail if another
|
|
215
|
+
* process has the destination open, in which case the error propagates.
|
|
216
|
+
*/
|
|
217
|
+
export const truncateHistory = async (filePath, maxLines) => {
|
|
218
|
+
if (maxLines <= 0) {
|
|
219
|
+
try {
|
|
220
|
+
await fs.unlink(filePath);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err &&
|
|
224
|
+
typeof err === 'object' &&
|
|
225
|
+
'code' in err &&
|
|
226
|
+
err.code === 'ENOENT') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const entries = await loadHistory(filePath);
|
|
234
|
+
if (entries.length <= maxLines)
|
|
235
|
+
return;
|
|
236
|
+
const kept = entries.slice(entries.length - maxLines);
|
|
237
|
+
const body = kept.map(encodeEntry).join('\n') + '\n';
|
|
238
|
+
const dir = path.dirname(filePath);
|
|
239
|
+
const base = path.basename(filePath);
|
|
240
|
+
const tmpPath = path.join(dir, `.${base}.${randomUUID()}.tmp`);
|
|
241
|
+
await fs.writeFile(tmpPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
242
|
+
try {
|
|
243
|
+
await fs.rename(tmpPath, filePath);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
// Best-effort cleanup; the temp file is harmless but noisy.
|
|
247
|
+
try {
|
|
248
|
+
await fs.unlink(tmpPath);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* ignore */
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the on-disk history file path psql would use, in order of
|
|
258
|
+
* precedence:
|
|
259
|
+
*
|
|
260
|
+
* 1. `$PSQL_HISTORY` if set and non-empty.
|
|
261
|
+
* 2. On Windows: `%APPDATA%\postgresql\psql_history`.
|
|
262
|
+
* 3. Otherwise: `$HOME/.psql_history` (falling back to `os.homedir()`
|
|
263
|
+
* if `$HOME` is unset, e.g. in some sandboxes).
|
|
264
|
+
*
|
|
265
|
+
* Pure function — `env` defaults to `process.env` but can be injected
|
|
266
|
+
* for tests.
|
|
267
|
+
*/
|
|
268
|
+
export const defaultHistoryPath = (env = process.env) => {
|
|
269
|
+
const explicit = env.PSQL_HISTORY;
|
|
270
|
+
if (explicit !== undefined && explicit.length > 0)
|
|
271
|
+
return explicit;
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
const appdata = env.APPDATA;
|
|
274
|
+
if (appdata !== undefined && appdata.length > 0) {
|
|
275
|
+
return path.join(appdata, 'postgresql', 'psql_history');
|
|
276
|
+
}
|
|
277
|
+
// Fall through to homedir() if APPDATA isn't set; matches psql's
|
|
278
|
+
// graceful degradation on a minimally-configured Windows session.
|
|
279
|
+
}
|
|
280
|
+
const home = env.HOME ?? os.homedir();
|
|
281
|
+
return path.join(home, '.psql_history');
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Resolve the effective HISTSIZE: the `HISTSIZE` env var (if a
|
|
285
|
+
* non-negative integer) or psql's compiled-in default of 500.
|
|
286
|
+
*
|
|
287
|
+
* Values that fail to parse as a non-negative integer fall back to the
|
|
288
|
+
* default — psql itself ignores malformed HISTSIZE and warns, but at
|
|
289
|
+
* this layer we don't have a logger.
|
|
290
|
+
*/
|
|
291
|
+
export const resolveHistSize = (env = process.env) => {
|
|
292
|
+
const raw = env.HISTSIZE;
|
|
293
|
+
if (raw === undefined || raw === '')
|
|
294
|
+
return DEFAULT_HISTSIZE;
|
|
295
|
+
const n = Number(raw);
|
|
296
|
+
if (!Number.isInteger(n) || n < 0)
|
|
297
|
+
return DEFAULT_HISTSIZE;
|
|
298
|
+
return n;
|
|
299
|
+
};
|
package/psql/io/input.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive line input for the psql REPL — the shared "read one line from
|
|
3
|
+
* the user" primitive used by `\prompt` and `\password`.
|
|
4
|
+
*
|
|
5
|
+
* Models upstream psql's password / prompt reading:
|
|
6
|
+
* - `simple_prompt` (`src/port/sprompt.c`) reads a line with optional echo
|
|
7
|
+
* suppression. On a TTY it disables terminal echo for the duration of the
|
|
8
|
+
* read (we emulate this with Node's raw mode + manual character handling);
|
|
9
|
+
* when stdin is NOT a TTY it falls back to a plain line read and the
|
|
10
|
+
* `echo` flag is a no-op — the line is still consumed. We match that: a
|
|
11
|
+
* non-interactive caller piping a password in still has it read.
|
|
12
|
+
* - `\prompt` (`exec_command_prompt`) reads with echo on; `\prompt -`
|
|
13
|
+
* (no-echo password form) and `\password` read with echo suppressed.
|
|
14
|
+
*
|
|
15
|
+
* The terminal handling is parameterised over the input/output streams so the
|
|
16
|
+
* non-TTY path (the only one testable without a real PTY) can be exercised in
|
|
17
|
+
* unit tests; production callers use {@link readLine} with the process
|
|
18
|
+
* defaults.
|
|
19
|
+
*/
|
|
20
|
+
import { createInterface } from 'node:readline';
|
|
21
|
+
/** True when `stream` is a TTY whose echo we can suppress via raw mode. */
|
|
22
|
+
const isRawCapableTty = (stream) => {
|
|
23
|
+
const s = stream;
|
|
24
|
+
return Boolean(s.isTTY) && typeof s.setRawMode === 'function';
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Read one line of input, optionally suppressing echo.
|
|
28
|
+
*
|
|
29
|
+
* - `echo: false` on a TTY → put the terminal in raw mode, echo nothing,
|
|
30
|
+
* accumulate characters until Enter, then restore cooked mode. Used for
|
|
31
|
+
* password entry (`\password`, `\prompt -`).
|
|
32
|
+
* - `echo: true`, or any non-TTY input → a plain line read. On a non-TTY the
|
|
33
|
+
* `echo` flag is a no-op: we still consume and return the line, matching
|
|
34
|
+
* upstream `simple_prompt`'s behaviour with redirected stdin.
|
|
35
|
+
*
|
|
36
|
+
* The returned string excludes the trailing newline. EOF before any newline
|
|
37
|
+
* resolves to whatever was typed so far (empty string if nothing).
|
|
38
|
+
*/
|
|
39
|
+
export const readLine = (prompt, opts) => {
|
|
40
|
+
const input = opts.input ?? process.stdin;
|
|
41
|
+
const output = opts.output ?? process.stderr;
|
|
42
|
+
if (!opts.echo && isRawCapableTty(input)) {
|
|
43
|
+
return readNoEchoTty(prompt, input, output);
|
|
44
|
+
}
|
|
45
|
+
return readEchoLine(prompt, input, output);
|
|
46
|
+
};
|
|
47
|
+
/** Plain, echoing (or non-TTY) line read via `node:readline`. */
|
|
48
|
+
const readEchoLine = (prompt, input, output) => {
|
|
49
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let settled = false;
|
|
52
|
+
const settle = (line) => {
|
|
53
|
+
if (settled)
|
|
54
|
+
return;
|
|
55
|
+
settled = true;
|
|
56
|
+
rl.close();
|
|
57
|
+
resolve(line);
|
|
58
|
+
};
|
|
59
|
+
if (prompt.length > 0)
|
|
60
|
+
output.write(prompt);
|
|
61
|
+
// Resolve on the first complete line. Closing without one (EOF) yields ''.
|
|
62
|
+
// We don't rely on `line` firing before `close`: whichever lands first
|
|
63
|
+
// wins, and a buffered final line is delivered as a `line` event.
|
|
64
|
+
rl.on('line', (l) => {
|
|
65
|
+
settle(l);
|
|
66
|
+
});
|
|
67
|
+
rl.on('close', () => {
|
|
68
|
+
settle('');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* No-echo read on a raw-capable TTY. Mirrors upstream `simple_prompt`'s
|
|
74
|
+
* echo-off branch: switch to raw mode, gather bytes, never echo them, and
|
|
75
|
+
* restore on Enter / EOF / interrupt. Backspace edits the buffer; Ctrl-C and
|
|
76
|
+
* Ctrl-D abort with whatever has been typed (empty on a clean Ctrl-C).
|
|
77
|
+
*/
|
|
78
|
+
const readNoEchoTty = (prompt, input, output) => {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
if (prompt.length > 0)
|
|
81
|
+
output.write(prompt);
|
|
82
|
+
input.setRawMode(true);
|
|
83
|
+
input.resume();
|
|
84
|
+
input.setEncoding('utf8');
|
|
85
|
+
let buf = '';
|
|
86
|
+
const finish = (result) => {
|
|
87
|
+
input.setRawMode(false);
|
|
88
|
+
input.pause();
|
|
89
|
+
input.removeListener('data', onData);
|
|
90
|
+
// Terminate the (un-echoed) line the user couldn't see themselves type.
|
|
91
|
+
output.write('\n');
|
|
92
|
+
resolve(result);
|
|
93
|
+
};
|
|
94
|
+
const onData = (chunk) => {
|
|
95
|
+
for (const ch of chunk) {
|
|
96
|
+
if (ch === '\n' || ch === '\r') {
|
|
97
|
+
finish(buf);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '') {
|
|
101
|
+
// Ctrl-C: cancel, return nothing.
|
|
102
|
+
finish('');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '') {
|
|
106
|
+
// Ctrl-D (EOF): return what we have.
|
|
107
|
+
finish(buf);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '' || ch === '\b') {
|
|
111
|
+
// DEL / Backspace: drop the last character.
|
|
112
|
+
buf = buf.slice(0, -1);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
buf += ch;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
input.on('data', onData);
|
|
119
|
+
});
|
|
120
|
+
};
|