@kazurolabs/psql-explorer 0.0.0-stage → 0.3.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 (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/RELEASES.md +87 -0
  4. package/dist/app.js +39 -0
  5. package/dist/cli.js +307 -0
  6. package/dist/commands/configgen.js +128 -0
  7. package/dist/commands/explore.js +388 -0
  8. package/dist/commands/terminal-prompt.js +68 -0
  9. package/dist/config/load.js +324 -0
  10. package/dist/config/resolve-environment.js +112 -0
  11. package/dist/config/resolvers/env.js +12 -0
  12. package/dist/config/resolvers/file.js +24 -0
  13. package/dist/config/resolvers/groups.js +1 -0
  14. package/dist/config/resolvers/index.js +49 -0
  15. package/dist/config/resolvers/literal.js +7 -0
  16. package/dist/config/resolvers/prompt.js +159 -0
  17. package/dist/config/resolvers/stored.js +13 -0
  18. package/dist/config/resolvers/types.js +18 -0
  19. package/dist/config/resolvers/vault.js +283 -0
  20. package/dist/config/schema.js +24 -0
  21. package/dist/config/source.js +105 -0
  22. package/dist/db/connect.js +55 -0
  23. package/dist/db/identifiers.js +6 -0
  24. package/dist/db/paths.js +14 -0
  25. package/dist/db/store.js +323 -0
  26. package/dist/db/tester.js +190 -0
  27. package/dist/db/validate.js +99 -0
  28. package/dist/environments.js +140 -0
  29. package/dist/explorer/alter.js +493 -0
  30. package/dist/explorer/catalog.js +414 -0
  31. package/dist/explorer/connect-entry.js +42 -0
  32. package/dist/explorer/cypher-parse.js +478 -0
  33. package/dist/explorer/cypher.js +963 -0
  34. package/dist/explorer/ddl.js +502 -0
  35. package/dist/explorer/dependents.js +222 -0
  36. package/dist/explorer/design-load.js +254 -0
  37. package/dist/explorer/design.js +45 -0
  38. package/dist/explorer/edit.js +108 -0
  39. package/dist/explorer/explain.js +37 -0
  40. package/dist/explorer/filter.js +121 -0
  41. package/dist/explorer/format.js +134 -0
  42. package/dist/explorer/graph.js +679 -0
  43. package/dist/explorer/gucs.js +31 -0
  44. package/dist/explorer/profile.js +102 -0
  45. package/dist/explorer/references.js +95 -0
  46. package/dist/explorer/rows.js +64 -0
  47. package/dist/explorer/schema-diff.js +360 -0
  48. package/dist/explorer/search.js +309 -0
  49. package/dist/explorer/security.js +152 -0
  50. package/dist/explorer/seed.js +1376 -0
  51. package/dist/explorer/session.js +122 -0
  52. package/dist/explorer/stats.js +135 -0
  53. package/dist/explorer/timeout.js +13 -0
  54. package/dist/prompts.js +52 -0
  55. package/dist/screens/environments.js +229 -0
  56. package/dist/screens/explorer.js +145 -0
  57. package/dist/screens/header.js +8 -0
  58. package/dist/screens/index.js +13 -0
  59. package/dist/screens/new-connection.js +178 -0
  60. package/dist/screens/panes/common.js +35 -0
  61. package/dist/screens/panes/history.js +36 -0
  62. package/dist/screens/panes/info.js +61 -0
  63. package/dist/screens/panes/query.js +113 -0
  64. package/dist/screens/panes/table.js +178 -0
  65. package/dist/screens/panes/tables.js +142 -0
  66. package/dist/screens/prompt-modal.js +18 -0
  67. package/dist/screens/record-view.js +42 -0
  68. package/dist/screens/result-grid.js +126 -0
  69. package/dist/screens/scroll.js +12 -0
  70. package/dist/screens/splash.js +78 -0
  71. package/dist/screens/terminal.js +25 -0
  72. package/dist/screens/types.js +1 -0
  73. package/dist/services.js +1 -0
  74. package/dist/startup.js +84 -0
  75. package/dist/web/server.js +988 -0
  76. package/package.json +69 -5
  77. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  78. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  79. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  80. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  81. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  82. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  83. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  84. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  85. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  86. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  87. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  88. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  89. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  90. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  91. package/web/dist/assets/button-ClA70PLR.js +1 -0
  92. package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
  93. package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
  94. package/web/dist/assets/copy-CCXzeW-s.js +1 -0
  95. package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
  96. package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
  97. package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
  98. package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
  99. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  100. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  101. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  102. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  103. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  104. package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
  105. package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
  106. package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
  107. package/web/dist/assets/index-CDcnUacl.js +10 -0
  108. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  109. package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
  110. package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
  111. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  112. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  113. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  114. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  115. package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
  116. package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
  117. package/web/dist/assets/route-DO6IZ7of.js +15 -0
  118. package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
  119. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  120. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  121. package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
  122. package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
  123. package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
  124. package/web/dist/assets/table-DfMz2f8M.js +1 -0
  125. package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
  126. package/web/dist/assets/zone-D9dXk8tn.js +5 -0
  127. package/web/dist/index.html +26 -0
@@ -0,0 +1,324 @@
1
+ import { isMap, isScalar, LineCounter, parseDocument } from 'yaml';
2
+ import { authModes, ConfigError, sourceNames, tiers, tlsModes, } from './schema.js';
3
+ import { createSource, SourceError } from './source.js';
4
+ export const environmentNamePattern = /^[a-z0-9][a-z0-9-_]*$/;
5
+ const envVariablePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+ const vaultPattern = /^[^#\s]+#[^#\s]+$/;
7
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ export const environmentsVariable = 'PSQL_EXPLORER_ENVIRONMENTS';
9
+ /**
10
+ * Loads the shared config from the base path: `base` (--config), else $PSQL_EXPLORER_ENVIRONMENTS,
11
+ * else none, in which case the app only has local connections. Never throws for a bad config: what
12
+ * is wrong is in `error`, and the environments that are fine are still in `config`. An environment
13
+ * Vault will not let this user read is left out silently; that is how access is granted.
14
+ */
15
+ export async function loadConfig(options) {
16
+ const given = options.base || (options.env ?? process.env)[environmentsVariable];
17
+ if (!given)
18
+ return {};
19
+ let source;
20
+ try {
21
+ source = createSource(given, options.vault);
22
+ }
23
+ catch (error) {
24
+ return { error: new ConfigError(given, [{ path: '', message: messageOf(error) }]) };
25
+ }
26
+ const problems = [];
27
+ const problem = (path, message, line) => {
28
+ problems.push({ path, message, ...(line === undefined ? {} : { line }) });
29
+ };
30
+ let names;
31
+ try {
32
+ options.onProgress?.('list of environments');
33
+ names = await source.list();
34
+ }
35
+ catch (error) {
36
+ // Nothing can be read from a base path that cannot be listed.
37
+ problem('', messageOf(error));
38
+ return { error: new ConfigError(source.base, problems), source };
39
+ }
40
+ const environments = [];
41
+ for (const [index, name] of names.entries()) {
42
+ options.onProgress?.(`${name} (${index + 1} of ${names.length})`);
43
+ if (!environmentNamePattern.test(name)) {
44
+ problem(name, `'${name}' is not a valid environment name; use lower-case letters, digits, - and _, starting with a letter or digit`);
45
+ continue;
46
+ }
47
+ try {
48
+ const file = await source.readEnvironment(name);
49
+ if (file)
50
+ environments.push(parseEnvironment(name, file.text, file.label, file.dir));
51
+ }
52
+ catch (error) {
53
+ if (error instanceof SourceError && error.denied)
54
+ continue;
55
+ for (const found of problemsOf(error, name))
56
+ problems.push(found);
57
+ if (!(error instanceof ConfigError))
58
+ problem(name, messageOf(error));
59
+ }
60
+ }
61
+ const config = { base: source.base, environments };
62
+ return {
63
+ config,
64
+ source,
65
+ ...(problems.length > 0 ? { error: new ConfigError(source.base, problems) } : {}),
66
+ };
67
+ }
68
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
69
+ // A ConfigError's problems, each under `prefix` so they say which file they are in.
70
+ const problemsOf = (error, prefix) => error instanceof ConfigError
71
+ ? error.problems.map((found) => ({
72
+ ...found,
73
+ path: found.path ? `${prefix}.${found.path}` : prefix,
74
+ }))
75
+ : [];
76
+ const parseYaml = (text, file) => {
77
+ const lineCounter = new LineCounter();
78
+ const doc = parseDocument(text, { lineCounter });
79
+ if (doc.errors.length > 0) {
80
+ throw new ConfigError(file, doc.errors.map((error) => ({
81
+ path: '',
82
+ line: error.linePos?.[0].line,
83
+ // yaml appends the location and a code excerpt to the first line.
84
+ message: (error.message.split('\n')[0] ?? error.message).replace(/ at line \d+, column \d+:?$/, ''),
85
+ })));
86
+ }
87
+ return new Checker(doc, lineCounter);
88
+ };
89
+ // Parses and validates the text of one environment's file; `dir` is where its `file` references
90
+ // are relative to. Problem paths are relative to the file ("tls.cert").
91
+ export function parseEnvironment(name, text, file, dir) {
92
+ const checker = parseYaml(text, file);
93
+ const environment = checker.environment(name, checker.toJS(), dir);
94
+ if (checker.problems.length > 0 || !environment)
95
+ throw new ConfigError(file, checker.problems);
96
+ return environment;
97
+ }
98
+ class Checker {
99
+ doc;
100
+ lines;
101
+ problems = [];
102
+ constructor(doc, lines) {
103
+ this.doc = doc;
104
+ this.lines = lines;
105
+ }
106
+ toJS() {
107
+ return this.doc.toJS();
108
+ }
109
+ environment(name, raw, dir) {
110
+ const path = [];
111
+ if (!isRecord(raw))
112
+ return this.fail(path, 'must be a mapping');
113
+ const before = this.problems.length;
114
+ this.unknownKeys(raw, path, [
115
+ 'tier',
116
+ 'protected',
117
+ 'host',
118
+ 'port',
119
+ 'database',
120
+ 'schema',
121
+ 'username',
122
+ 'password',
123
+ 'auth',
124
+ 'tls',
125
+ 'readOnly',
126
+ 'confirm',
127
+ 'seeder',
128
+ 'designer',
129
+ ]);
130
+ const tier = this.choice(raw.tier, [...path, 'tier'], tiers, undefined);
131
+ const auth = this.choice(raw.auth, [...path, 'auth'], authModes, 'password');
132
+ const host = this.value(raw.host, [...path, 'host'], { required: true });
133
+ const port = this.value(raw.port, [...path, 'port'], { required: true, number: true });
134
+ const database = this.value(raw.database, [...path, 'database'], { required: true });
135
+ const schema = this.value(raw.schema, [...path, 'schema']);
136
+ const username = this.value(raw.username, [...path, 'username'], { required: true });
137
+ const password = this.value(raw.password, [...path, 'password']);
138
+ const isProtected = raw.protected === undefined ? undefined : this.flag(raw.protected, [...path, 'protected']);
139
+ const readOnly = raw.readOnly === undefined ? undefined : this.flag(raw.readOnly, [...path, 'readOnly']);
140
+ const confirm = raw.confirm === undefined ? undefined : this.flag(raw.confirm, [...path, 'confirm']);
141
+ const seeder = raw.seeder === undefined ? undefined : this.flag(raw.seeder, [...path, 'seeder']);
142
+ const designer = raw.designer === undefined ? undefined : this.flag(raw.designer, [...path, 'designer']);
143
+ const tls = this.tls(raw.tls, [...path, 'tls']);
144
+ if (auth === 'password' && raw.password === undefined) {
145
+ this.add([...path, 'password'], 'is required when auth is password');
146
+ }
147
+ if (auth === 'none' && raw.password !== undefined) {
148
+ this.add([...path, 'password'], 'must not be set when auth is none', true);
149
+ }
150
+ if (this.problems.length > before)
151
+ return undefined;
152
+ const guarded = isProtected ?? tier === 'production';
153
+ return {
154
+ name,
155
+ dir,
156
+ tier: tier,
157
+ protected: guarded,
158
+ readOnly: readOnly ?? guarded,
159
+ confirm: confirm ?? guarded,
160
+ seeder: seeder ?? (tier === 'development' || tier === 'local'),
161
+ designer: designer ?? (tier === 'development' || tier === 'local'),
162
+ host: host,
163
+ port: port,
164
+ database: database,
165
+ ...(schema === undefined ? {} : { schema }),
166
+ username: username,
167
+ auth: auth,
168
+ ...(password === undefined ? {} : { password }),
169
+ tls: tls,
170
+ };
171
+ }
172
+ tls(raw, path) {
173
+ if (raw === undefined)
174
+ return { mode: 'disable' };
175
+ if (!isRecord(raw))
176
+ return this.fail(path, 'must be a mapping');
177
+ const before = this.problems.length;
178
+ this.unknownKeys(raw, path, ['mode', 'ca', 'cert', 'key', 'keyPassphrase']);
179
+ const mode = this.choice(raw.mode, [...path, 'mode'], tlsModes, 'disable');
180
+ const ca = this.value(raw.ca, [...path, 'ca']);
181
+ const cert = this.value(raw.cert, [...path, 'cert']);
182
+ const key = this.value(raw.key, [...path, 'key']);
183
+ const keyPassphrase = this.value(raw.keyPassphrase, [...path, 'keyPassphrase']);
184
+ if (raw.cert !== undefined && raw.key === undefined) {
185
+ this.add([...path, 'cert'], 'cert and key must be given together', true);
186
+ }
187
+ if (raw.key !== undefined && raw.cert === undefined) {
188
+ this.add([...path, 'key'], 'cert and key must be given together', true);
189
+ }
190
+ if ((mode === 'verify-ca' || mode === 'verify-full') && raw.ca === undefined) {
191
+ this.add([...path, 'ca'], `is required when tls mode is ${mode}`);
192
+ }
193
+ if (this.problems.length > before)
194
+ return undefined;
195
+ const optional = { ca, cert, key, keyPassphrase };
196
+ return {
197
+ mode: mode,
198
+ ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined)),
199
+ };
200
+ }
201
+ // A literal text setting.
202
+ text(raw, path, options) {
203
+ if (raw === undefined) {
204
+ return options.required ? this.fail(path, 'is required') : options.fallback;
205
+ }
206
+ if (typeof raw !== 'string' || raw.trim() === '') {
207
+ return this.fail(path, 'must be a non-empty string');
208
+ }
209
+ if (options.maxBytes !== undefined && Buffer.byteLength(raw) > options.maxBytes) {
210
+ return this.fail(path, `must be at most ${options.maxBytes} bytes`);
211
+ }
212
+ return raw;
213
+ }
214
+ // A setting that is always a literal, one of a fixed set.
215
+ choice(raw, path, allowed, fallback) {
216
+ if (raw === undefined)
217
+ return fallback ?? this.fail(path, 'is required');
218
+ if (typeof raw === 'string' && allowed.includes(raw))
219
+ return raw;
220
+ return this.fail(path, `must be one of: ${allowed.join(', ')}`);
221
+ }
222
+ flag(raw, path) {
223
+ if (raw === undefined)
224
+ return false;
225
+ return typeof raw === 'boolean' ? raw : this.fail(path, 'must be true or false');
226
+ }
227
+ // A literal, or a mapping of `source` and `value` saying where the value comes from.
228
+ value(raw, path, options = {}) {
229
+ if (raw === undefined)
230
+ return options.required ? this.fail(path, 'is required') : undefined;
231
+ if (typeof raw === 'string') {
232
+ return raw === '' ? this.fail(path, 'must not be empty') : raw;
233
+ }
234
+ if (typeof raw === 'number' && options.number)
235
+ return raw;
236
+ if (isRecord(raw))
237
+ return this.source(raw, path, options);
238
+ const hint = options.number ? 'a number, a string' : 'a string';
239
+ return this.fail(path, typeof raw === 'number'
240
+ ? 'must be a string (put it in quotes) or a mapping with source and value'
241
+ : `must be ${hint} or a mapping with source and value`);
242
+ }
243
+ source(raw, path, options) {
244
+ const allowed = sourceNames.join(', ');
245
+ this.unknownKeys(raw, path, ['source', 'value']);
246
+ const named = raw.source;
247
+ if (named === undefined)
248
+ return this.fail(path, `needs a source: one of ${allowed}`);
249
+ const at = [...path, 'source'];
250
+ if (typeof named !== 'string' || !sourceNames.includes(named)) {
251
+ return this.fail(at, `unknown source; use one of: ${allowed}`);
252
+ }
253
+ const valueAt = [...path, 'value'];
254
+ const value = raw.value;
255
+ if (named === 'prompt') {
256
+ return value === undefined
257
+ ? { prompt: true }
258
+ : this.fail(valueAt, 'must not be given: the value is asked for', true);
259
+ }
260
+ if (value === undefined)
261
+ return this.fail(valueAt, 'is required');
262
+ switch (named) {
263
+ case 'plaintext':
264
+ case 'inline':
265
+ if (typeof value === 'number' && options.number)
266
+ return value;
267
+ return typeof value === 'string' && value !== ''
268
+ ? value
269
+ : this.fail(valueAt, 'must be the text itself (put a number in quotes)');
270
+ case 'env':
271
+ return typeof value === 'string' && envVariablePattern.test(value)
272
+ ? { env: value }
273
+ : this.fail(valueAt, 'must be an environment variable name');
274
+ case 'file':
275
+ return typeof value === 'string' && value !== ''
276
+ ? { file: value }
277
+ : this.fail(valueAt, 'must be a file path');
278
+ case 'stored':
279
+ return typeof value === 'string' && value !== ''
280
+ ? { stored: value }
281
+ : this.fail(valueAt, 'must be the name of a stored secret');
282
+ case 'vault':
283
+ return typeof value === 'string' && vaultPattern.test(value)
284
+ ? { vault: value }
285
+ : this.fail(valueAt, 'must look like "path#key", e.g. secret/data/db/prod#password');
286
+ }
287
+ }
288
+ unknownKeys(raw, path, allowed) {
289
+ for (const key of Object.keys(raw)) {
290
+ if (!allowed.includes(key)) {
291
+ this.add([...path, key], `unknown key; expected one of: ${allowed.join(', ')}`, true);
292
+ }
293
+ }
294
+ }
295
+ fail(path, message, atKey = false) {
296
+ this.add(path, message, atKey);
297
+ return undefined;
298
+ }
299
+ add(path, message, atKey = false) {
300
+ this.problems.push({ path: path.join('.'), line: this.lineOf(path, atKey), message });
301
+ }
302
+ // The line of the value at `path` (or of its key), else of the nearest enclosing node that
303
+ // exists, so a missing field points at the mapping it should be in.
304
+ lineOf(path, atKey) {
305
+ for (let length = path.length; length >= 0; length--) {
306
+ const here = path.slice(0, length);
307
+ let offset;
308
+ if (atKey && length === path.length && length > 0) {
309
+ const parent = this.doc.getIn(here.slice(0, -1), true);
310
+ const pair = isMap(parent)
311
+ ? parent.items.find((item) => isScalar(item.key) && String(item.key.value) === here.at(-1))
312
+ : undefined;
313
+ offset = isScalar(pair?.key) ? pair.key.range?.[0] : undefined;
314
+ }
315
+ else {
316
+ const node = length === 0 ? this.doc.contents : this.doc.getIn(here, true);
317
+ offset = node?.range?.[0];
318
+ }
319
+ if (offset !== undefined)
320
+ return this.lines.linePos(offset).line;
321
+ }
322
+ return undefined;
323
+ }
324
+ }
@@ -0,0 +1,112 @@
1
+ import { hostError, identifierError, passwordError, portError } from '../db/validate.js';
2
+ import { defaultRegistry, describeValue, resolveValue, PromptRequiredError, ResolveError, } from './resolvers/index.js';
3
+ import { defaultSchemaName } from './schema.js';
4
+ /**
5
+ * Resolves the fields one at a time, in a fixed order (host, port, database, username, password,
6
+ * then the TLS fields) so any prompts appear in a predictable sequence. Every resolved value is
7
+ * checked with the same rules as the connection form; a failure is a ResolveError naming the
8
+ * field, and never contains the value.
9
+ */
10
+ export async function resolveEnvironment(env, ctx) {
11
+ const registry = ctx.registry ?? defaultRegistry();
12
+ const fail = (field, reason) => new ResolveError(env.name, field, reason);
13
+ const resolve = (field, value) => resolveValue(value, {
14
+ envName: env.name,
15
+ field,
16
+ configDir: env.dir,
17
+ interactive: ctx.interactive,
18
+ signal: ctx.signal,
19
+ }, registry);
20
+ // Text from a file or a prompt often carries a stray newline or space.
21
+ const checked = async (field, value, rule) => {
22
+ const text = (await resolve(field, value)).trim();
23
+ const message = rule(text);
24
+ if (message)
25
+ throw fail(field, message);
26
+ return text;
27
+ };
28
+ const pem = async (field, value) => {
29
+ const text = await resolve(field, value);
30
+ if (!text.includes('-----BEGIN')) {
31
+ throw fail(field, 'is not PEM text (no "-----BEGIN" line found)');
32
+ }
33
+ return text;
34
+ };
35
+ if (env.confirm) {
36
+ // Before anything is looked up, so no secret is fetched for an environment nobody confirmed.
37
+ const prompt = registry.get('prompt');
38
+ if (!prompt)
39
+ throw new PromptRequiredError(env.name, 'confirm');
40
+ await prompt.confirm({ envName: env.name, interactive: ctx.interactive, signal: ctx.signal });
41
+ }
42
+ const host = await checked('host', env.host, hostError);
43
+ const port = Number(await checked('port', env.port, portError));
44
+ const database = await checked('database', env.database, (v) => identifierError('Database', v));
45
+ const schema = env.schema === undefined
46
+ ? defaultSchemaName
47
+ : await checked('schema', env.schema, (v) => identifierError('Schema', v));
48
+ const username = await checked('username', env.username, (v) => identifierError('Username', v));
49
+ let password;
50
+ if (env.auth === 'password' && env.password !== undefined) {
51
+ const text = await resolve('password', env.password);
52
+ const message = passwordError(text);
53
+ if (message)
54
+ throw fail('password', message);
55
+ password = text;
56
+ }
57
+ const tls = { mode: env.tls.mode };
58
+ // With TLS off these are never used, so they are not looked up (or prompted for).
59
+ if (env.tls.mode !== 'disable') {
60
+ if (env.tls.ca !== undefined)
61
+ tls.ca = await pem('tls.ca', env.tls.ca);
62
+ if (env.tls.cert !== undefined)
63
+ tls.cert = await pem('tls.cert', env.tls.cert);
64
+ if (env.tls.key !== undefined)
65
+ tls.key = await pem('tls.key', env.tls.key);
66
+ if (env.tls.keyPassphrase !== undefined) {
67
+ tls.keyPassphrase = await resolve('tls.keyPassphrase', env.tls.keyPassphrase);
68
+ }
69
+ }
70
+ return {
71
+ name: env.name,
72
+ tier: env.tier,
73
+ protected: env.protected,
74
+ readOnly: env.readOnly,
75
+ host,
76
+ port,
77
+ database,
78
+ username,
79
+ ...(password === undefined ? {} : { password }),
80
+ schema,
81
+ tls,
82
+ };
83
+ }
84
+ /** Where each field of the environment comes from, for display; never the values. */
85
+ export function describeEnvironment(env, registry = defaultRegistry()) {
86
+ const labels = {};
87
+ const add = (field, value) => {
88
+ if (value === undefined)
89
+ return;
90
+ const ctx = { envName: env.name, field, configDir: '', interactive: false };
91
+ try {
92
+ labels[field] = describeValue(value, ctx, registry);
93
+ }
94
+ catch {
95
+ // A kind with no resolver yet still has a safe label: its name and reference.
96
+ const [kind = 'unknown', ref] = Object.entries(value)[0] ?? [];
97
+ labels[field] = kind === 'prompt' ? 'prompt' : `${kind}: ${String(ref)}`;
98
+ }
99
+ };
100
+ add('host', env.host);
101
+ add('port', env.port);
102
+ add('database', env.database);
103
+ add('schema', env.schema);
104
+ add('username', env.username);
105
+ if (env.auth === 'password')
106
+ add('password', env.password);
107
+ add('tls.ca', env.tls.ca);
108
+ add('tls.cert', env.tls.cert);
109
+ add('tls.key', env.tls.key);
110
+ add('tls.keyPassphrase', env.tls.keyPassphrase);
111
+ return labels;
112
+ }
@@ -0,0 +1,12 @@
1
+ import { ResolveError } from './types.js';
2
+ export const createEnvResolver = (environment = process.env) => ({
3
+ kind: 'env',
4
+ resolve({ env }, ctx) {
5
+ const value = environment[env];
6
+ if (!value) {
7
+ return Promise.reject(new ResolveError(ctx.envName, ctx.field, `environment variable ${env} is not set`));
8
+ }
9
+ return Promise.resolve(value);
10
+ },
11
+ describe: ({ env }) => `env: ${env}`,
12
+ });
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ResolveError } from './types.js';
4
+ // The value is the UTF-8 content of a file, relative to the config file's directory.
5
+ export const fileResolver = {
6
+ kind: 'file',
7
+ async resolve({ file }, ctx) {
8
+ const target = path.resolve(ctx.configDir, file);
9
+ let content;
10
+ try {
11
+ content = await readFile(target, { encoding: 'utf8', signal: ctx.signal });
12
+ }
13
+ catch (error) {
14
+ // Only the error code is reported: it is enough, and nothing in it can be file content.
15
+ const code = error.code;
16
+ throw new ResolveError(ctx.envName, ctx.field, `cannot read file ${file}${code ? ` (${code})` : ''}`);
17
+ }
18
+ if (content === '') {
19
+ throw new ResolveError(ctx.envName, ctx.field, `file ${file} is empty`);
20
+ }
21
+ return content;
22
+ },
23
+ describe: ({ file }) => `file: ${file}`,
24
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { refKinds } from '../schema.js';
2
+ import { createEnvResolver } from './env.js';
3
+ import { fileResolver } from './file.js';
4
+ import { literalResolver } from './literal.js';
5
+ import { createStoredResolver } from './stored.js';
6
+ import { createVaultClient } from './vault.js';
7
+ import { createPromptResolver } from './prompt.js';
8
+ import { ResolveError } from './types.js';
9
+ export { ResolveError } from './types.js';
10
+ export { createEnvResolver } from './env.js';
11
+ export { fileResolver } from './file.js';
12
+ export { literalResolver } from './literal.js';
13
+ export { createStoredResolver } from './stored.js';
14
+ export { createVaultClient, createVaultResolver, vaultTimeoutMs, metadataPath, } from './vault.js';
15
+ export { createPromptResolver, PromptRequiredError, } from './prompt.js';
16
+ export const createRegistry = (resolvers) => new Map(resolvers.map((resolver) => [resolver.kind, resolver]));
17
+ // `prompter` enables `prompt` values and `store` `stored` ones; without them those
18
+ // fail with "no resolver".
19
+ export const defaultRegistry = (environment, prompter, store,
20
+ // Share one Vault client (its login token) with whoever else reads from Vault.
21
+ vault = createVaultClient({ env: environment })) => createRegistry([
22
+ literalResolver,
23
+ createEnvResolver(environment),
24
+ fileResolver,
25
+ vault.resolver,
26
+ ...(prompter ? [createPromptResolver(prompter)] : []),
27
+ ...(store ? [createStoredResolver(store)] : []),
28
+ ]);
29
+ const kindOf = (value) => {
30
+ if (typeof value !== 'object')
31
+ return 'literal';
32
+ return refKinds.find((kind) => kind in value) ?? 'unknown';
33
+ };
34
+ const lookup = (registry, value, ctx) => {
35
+ const kind = kindOf(value);
36
+ const resolver = registry.get(kind);
37
+ if (!resolver) {
38
+ throw new ResolveError(ctx.envName, ctx.field, `no resolver for "${kind}" values`);
39
+ }
40
+ return resolver;
41
+ };
42
+ export async function resolveValue(value, ctx, registry = defaultRegistry()) {
43
+ const resolver = lookup(registry, value, ctx);
44
+ if (ctx.signal?.aborted)
45
+ throw new ResolveError(ctx.envName, ctx.field, 'cancelled');
46
+ return resolver.resolve(value, ctx);
47
+ }
48
+ // Where the value comes from, safe to show.
49
+ export const describeValue = (value, ctx, registry = defaultRegistry()) => lookup(registry, value, ctx).describe(value);
@@ -0,0 +1,7 @@
1
+ // The value is written in the config file itself.
2
+ export const literalResolver = {
3
+ kind: 'literal',
4
+ resolve: (ref) => Promise.resolve(String(ref)),
5
+ // The value may be a secret, so it is never echoed.
6
+ describe: () => 'literal',
7
+ };