@kazurolabs/psql-explorer 0.0.0-stage → 0.1.0

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