@kazurolabs/psql-explorer 0.1.0 → 0.3.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.
- package/README.md +229 -18
- package/RELEASES.md +95 -0
- package/dist/cli.js +57 -13
- package/dist/commands/configgen.js +25 -25
- package/dist/commands/explore.js +86 -4
- package/dist/config/load.js +11 -73
- package/dist/config/resolve-environment.js +10 -62
- package/dist/config/resolvers/index.js +1 -1
- package/dist/config/resolvers/prompt.js +2 -2
- package/dist/config/resolvers/vault.js +1 -83
- package/dist/db/store.js +57 -0
- package/dist/environments.js +5 -2
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +31 -0
- package/dist/explorer/connect-entry.js +29 -18
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +44 -3
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +100 -13
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +25 -1
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/screens/environments.js +6 -3
- package/dist/screens/explorer.js +0 -3
- package/dist/screens/splash.js +4 -2
- package/dist/web/server.js +655 -22
- package/package.json +4 -1
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-BiFx4Lyw.js +1 -0
- package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
- package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
- package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
- package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
- package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
- package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
- package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
- package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
- package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
- package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
- package/web/dist/assets/index-CpHnjRLv.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
- package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
- package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
- package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
- package/web/dist/assets/route-7eu-iAqD.js +15 -0
- package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
- package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
- package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
- package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
- package/web/dist/assets/table-Drc_n1gF.js +1 -0
- package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
- package/web/dist/assets/zone-CHDOrDBF.js +5 -0
- package/web/dist/index.html +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- package/web/dist/assets/index-CMXJRL9O.css +0 -2
package/dist/commands/explore.js
CHANGED
|
@@ -5,6 +5,7 @@ import { addressOf, findEnvironment, listEnvironments, } from '../environments.j
|
|
|
5
5
|
import { describeTable, listSchemas, listTables, parseTableRef, serverInfo, tableLabel, } from '../explorer/catalog.js';
|
|
6
6
|
import { commandSummary, formatBytes, formatDuration, formatEstimate, formatResult, outputFormats, } from '../explorer/format.js';
|
|
7
7
|
import { browseRows, DEFAULT_PAGE_SIZE } from '../explorer/rows.js';
|
|
8
|
+
import { compareSchemas } from '../explorer/schema-diff.js';
|
|
8
9
|
import { DEFAULT_MAX_ROWS, describeQueryError, } from '../explorer/session.js';
|
|
9
10
|
import { connectEntry } from '../explorer/connect-entry.js';
|
|
10
11
|
export const commandNames = [
|
|
@@ -16,6 +17,7 @@ export const commandNames = [
|
|
|
16
17
|
'query',
|
|
17
18
|
'info',
|
|
18
19
|
'history',
|
|
20
|
+
'diff',
|
|
19
21
|
];
|
|
20
22
|
export const isCommand = (name) => commandNames.includes(name);
|
|
21
23
|
/** Thrown for a wrong command line: exit code 2. */
|
|
@@ -41,8 +43,7 @@ function formatOf(flags) {
|
|
|
41
43
|
}
|
|
42
44
|
return format;
|
|
43
45
|
}
|
|
44
|
-
function environmentFor(options) {
|
|
45
|
-
const { env } = options.flags;
|
|
46
|
+
function environmentFor(options, env = options.flags.env) {
|
|
46
47
|
if (!env)
|
|
47
48
|
throw new UsageError(`${options.command} needs --env <name>`);
|
|
48
49
|
const entry = findEnvironment(options.services, env);
|
|
@@ -66,8 +67,6 @@ async function withSession(options, work) {
|
|
|
66
67
|
}
|
|
67
68
|
finally {
|
|
68
69
|
await session.close();
|
|
69
|
-
// Credentials issued for this command end with it.
|
|
70
|
-
await options.services.groups?.release(entry.name);
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
function envs(options) {
|
|
@@ -253,6 +252,87 @@ async function info(options, session, entry) {
|
|
|
253
252
|
]), format));
|
|
254
253
|
return 0;
|
|
255
254
|
}
|
|
255
|
+
const indent = (text, prefix) => text
|
|
256
|
+
.split('\n')
|
|
257
|
+
.map((line) => `${prefix}${line}`)
|
|
258
|
+
.join('\n');
|
|
259
|
+
/** One object that differs, as text: its parts with each side's value, or a line by line diff. */
|
|
260
|
+
function describeChange(change, a, b) {
|
|
261
|
+
const name = change.kind === 'schema' || change.kind === 'extension'
|
|
262
|
+
? change.name
|
|
263
|
+
: `${change.schema}.${change.name}`;
|
|
264
|
+
const status = change.status === 'only-a'
|
|
265
|
+
? `only in ${a}`
|
|
266
|
+
: change.status === 'only-b'
|
|
267
|
+
? `only in ${b}`
|
|
268
|
+
: 'differs';
|
|
269
|
+
const out = [`${change.kind} ${name}: ${status}`];
|
|
270
|
+
const width = Math.max(a.length, b.length) + 1;
|
|
271
|
+
for (const part of change.parts) {
|
|
272
|
+
const value = part.a ?? part.b ?? '';
|
|
273
|
+
const label = change.status === 'changed' && (part.a === null || part.b === null)
|
|
274
|
+
? `${part.part}, only in ${part.a === null ? b : a}`
|
|
275
|
+
: part.part;
|
|
276
|
+
if (change.status !== 'changed' || part.a === null || part.b === null) {
|
|
277
|
+
out.push(value.includes('\n') ? ` ${label}:\n${indent(value, ' ')}` : ` ${label}: ${value}`);
|
|
278
|
+
}
|
|
279
|
+
else if (part.lines) {
|
|
280
|
+
out.push(` ${part.part} (- ${a}, + ${b})`);
|
|
281
|
+
for (const line of part.lines) {
|
|
282
|
+
out.push(` ${line.side === 'a' ? '-' : line.side === 'b' ? '+' : ' '} ${line.text}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
out.push(` ${part.part}`, ` ${`${a}:`.padEnd(width)} ${part.a}`, ` ${`${b}:`.padEnd(width)} ${part.b}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out.join('\n');
|
|
290
|
+
}
|
|
291
|
+
async function diff(options) {
|
|
292
|
+
const format = formatOf(options.flags);
|
|
293
|
+
const entry = environmentFor(options);
|
|
294
|
+
if (!options.flags.with)
|
|
295
|
+
throw new UsageError('diff needs --with <name>, the environment to compare with');
|
|
296
|
+
const other = environmentFor(options, options.flags.with);
|
|
297
|
+
const { schema, withSchema } = options.flags;
|
|
298
|
+
const session = await connect(options, entry);
|
|
299
|
+
let found;
|
|
300
|
+
try {
|
|
301
|
+
const otherSession = await connect(options, other);
|
|
302
|
+
try {
|
|
303
|
+
found = await compareSchemas(session, otherSession, { schema, withSchema });
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
await otherSession.close();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await session.close();
|
|
311
|
+
}
|
|
312
|
+
if (format === 'json') {
|
|
313
|
+
json(options, { a: entry.name, b: other.name, ...found });
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
if (format === 'csv') {
|
|
317
|
+
options.out(formatResult(result(['schema', 'kind', 'name', 'status', 'part', entry.name, other.name], found.changes.flatMap((change) => (change.parts.length > 0 ? change.parts : [{ part: null, a: null, b: null }]).map((part) => [
|
|
318
|
+
change.schema,
|
|
319
|
+
change.kind,
|
|
320
|
+
change.name,
|
|
321
|
+
change.status,
|
|
322
|
+
part.part,
|
|
323
|
+
part.a,
|
|
324
|
+
part.b,
|
|
325
|
+
]))), 'csv'));
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
for (const change of found.changes)
|
|
329
|
+
options.out(`${describeChange(change, entry.name, other.name)}\n\n`);
|
|
330
|
+
const differ = found.changes.length;
|
|
331
|
+
options.out(differ === 0
|
|
332
|
+
? `${entry.name} and ${other.name} are the same (${found.same} objects).\n`
|
|
333
|
+
: `${differ} ${differ === 1 ? 'object differs' : 'objects differ'}, ${found.same} the same.\n`);
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
256
336
|
function history(options) {
|
|
257
337
|
const format = formatOf(options.flags);
|
|
258
338
|
const entry = environmentFor(options);
|
|
@@ -296,6 +376,8 @@ export async function runCommand(options) {
|
|
|
296
376
|
}
|
|
297
377
|
case 'info':
|
|
298
378
|
return await withSession(options, (session, entry) => info(options, session, entry));
|
|
379
|
+
case 'diff':
|
|
380
|
+
return await diff(options);
|
|
299
381
|
}
|
|
300
382
|
}
|
|
301
383
|
catch (error) {
|
package/dist/config/load.js
CHANGED
|
@@ -4,7 +4,6 @@ import { createSource, SourceError } from './source.js';
|
|
|
4
4
|
export const environmentNamePattern = /^[a-z0-9][a-z0-9-_]*$/;
|
|
5
5
|
const envVariablePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6
6
|
const vaultPattern = /^[^#\s]+#[^#\s]+$/;
|
|
7
|
-
const vaultRolePattern = /^[^#\s/][^#\s]*$/;
|
|
8
7
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
8
|
export const environmentsVariable = 'PSQL_EXPLORER_ENVIRONMENTS';
|
|
10
9
|
/**
|
|
@@ -121,38 +120,28 @@ class Checker {
|
|
|
121
120
|
'schema',
|
|
122
121
|
'username',
|
|
123
122
|
'password',
|
|
124
|
-
'credentials',
|
|
125
123
|
'auth',
|
|
126
124
|
'tls',
|
|
127
125
|
'readOnly',
|
|
128
126
|
'confirm',
|
|
127
|
+
'seeder',
|
|
128
|
+
'designer',
|
|
129
129
|
]);
|
|
130
130
|
const tier = this.choice(raw.tier, [...path, 'tier'], tiers, undefined);
|
|
131
131
|
const auth = this.choice(raw.auth, [...path, 'auth'], authModes, 'password');
|
|
132
132
|
const host = this.value(raw.host, [...path, 'host'], { required: true });
|
|
133
133
|
const port = this.value(raw.port, [...path, 'port'], { required: true, number: true });
|
|
134
134
|
const database = this.value(raw.database, [...path, 'database'], { required: true });
|
|
135
|
-
const credentials = this.credentials(raw.credentials, [...path, 'credentials']);
|
|
136
|
-
const hasCredentials = raw.credentials !== undefined;
|
|
137
135
|
const schema = this.value(raw.schema, [...path, 'schema']);
|
|
138
|
-
const username = this.value(raw.username, [...path, 'username'], { required:
|
|
136
|
+
const username = this.value(raw.username, [...path, 'username'], { required: true });
|
|
139
137
|
const password = this.value(raw.password, [...path, 'password']);
|
|
140
138
|
const isProtected = raw.protected === undefined ? undefined : this.flag(raw.protected, [...path, 'protected']);
|
|
141
139
|
const readOnly = raw.readOnly === undefined ? undefined : this.flag(raw.readOnly, [...path, 'readOnly']);
|
|
142
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
143
|
const tls = this.tls(raw.tls, [...path, 'tls']);
|
|
144
|
-
if (
|
|
145
|
-
// Username and password come from Vault: setting them too would be two answers.
|
|
146
|
-
for (const field of ['username', 'password']) {
|
|
147
|
-
if (raw[field] !== undefined) {
|
|
148
|
-
this.add([...path, field], 'must not be set when credentials is given: Vault supplies it', true);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (auth === 'none') {
|
|
152
|
-
this.add([...path, 'auth'], 'must be password when credentials is given', true);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
else if (auth === 'password' && raw.password === undefined) {
|
|
144
|
+
if (auth === 'password' && raw.password === undefined) {
|
|
156
145
|
this.add([...path, 'password'], 'is required when auth is password');
|
|
157
146
|
}
|
|
158
147
|
if (auth === 'none' && raw.password !== undefined) {
|
|
@@ -168,12 +157,13 @@ class Checker {
|
|
|
168
157
|
protected: guarded,
|
|
169
158
|
readOnly: readOnly ?? guarded,
|
|
170
159
|
confirm: confirm ?? guarded,
|
|
160
|
+
seeder: seeder ?? (tier === 'development' || tier === 'local'),
|
|
161
|
+
designer: designer ?? (tier === 'development' || tier === 'local'),
|
|
171
162
|
host: host,
|
|
172
163
|
port: port,
|
|
173
164
|
database: database,
|
|
174
165
|
...(schema === undefined ? {} : { schema }),
|
|
175
|
-
|
|
176
|
-
...(credentials === undefined ? {} : { credentials }),
|
|
166
|
+
username: username,
|
|
177
167
|
auth: auth,
|
|
178
168
|
...(password === undefined ? {} : { password }),
|
|
179
169
|
tls: tls,
|
|
@@ -185,9 +175,8 @@ class Checker {
|
|
|
185
175
|
if (!isRecord(raw))
|
|
186
176
|
return this.fail(path, 'must be a mapping');
|
|
187
177
|
const before = this.problems.length;
|
|
188
|
-
this.unknownKeys(raw, path, ['mode', '
|
|
178
|
+
this.unknownKeys(raw, path, ['mode', 'ca', 'cert', 'key', 'keyPassphrase']);
|
|
189
179
|
const mode = this.choice(raw.mode, [...path, 'mode'], tlsModes, 'disable');
|
|
190
|
-
const client = this.client(raw.client, [...path, 'client']);
|
|
191
180
|
const ca = this.value(raw.ca, [...path, 'ca']);
|
|
192
181
|
const cert = this.value(raw.cert, [...path, 'cert']);
|
|
193
182
|
const key = this.value(raw.key, [...path, 'key']);
|
|
@@ -198,20 +187,7 @@ class Checker {
|
|
|
198
187
|
if (raw.key !== undefined && raw.cert === undefined) {
|
|
199
188
|
this.add([...path, 'key'], 'cert and key must be given together', true);
|
|
200
189
|
}
|
|
201
|
-
if (raw.
|
|
202
|
-
// The certificate, its key and (unless given) the CA all come from Vault.
|
|
203
|
-
for (const field of ['cert', 'key', 'keyPassphrase']) {
|
|
204
|
-
if (raw[field] !== undefined) {
|
|
205
|
-
this.add([...path, field], 'must not be set when client is given: Vault supplies the certificate and key', true);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (mode === 'disable') {
|
|
209
|
-
this.add([...path, 'client'], 'has no effect when tls mode is disable', true);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if ((mode === 'verify-ca' || mode === 'verify-full') &&
|
|
213
|
-
raw.ca === undefined &&
|
|
214
|
-
raw.client === undefined) {
|
|
190
|
+
if ((mode === 'verify-ca' || mode === 'verify-full') && raw.ca === undefined) {
|
|
215
191
|
this.add([...path, 'ca'], `is required when tls mode is ${mode}`);
|
|
216
192
|
}
|
|
217
193
|
if (this.problems.length > before)
|
|
@@ -219,47 +195,9 @@ class Checker {
|
|
|
219
195
|
const optional = { ca, cert, key, keyPassphrase };
|
|
220
196
|
return {
|
|
221
197
|
mode: mode,
|
|
222
|
-
...(client === undefined ? {} : { client }),
|
|
223
198
|
...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined)),
|
|
224
199
|
};
|
|
225
200
|
}
|
|
226
|
-
credentials(raw, path) {
|
|
227
|
-
if (raw === undefined)
|
|
228
|
-
return undefined;
|
|
229
|
-
if (!isRecord(raw)) {
|
|
230
|
-
return this.fail(path, 'must be a mapping: source: vault-database and value: <role path>');
|
|
231
|
-
}
|
|
232
|
-
this.unknownKeys(raw, path, ['source', 'value']);
|
|
233
|
-
this.sourceIs(raw, path, 'vault-database');
|
|
234
|
-
const vaultDatabase = this.vaultPath(raw.value, [...path, 'value'], 'database/creds/<role>');
|
|
235
|
-
return vaultDatabase === undefined ? undefined : { vaultDatabase };
|
|
236
|
-
}
|
|
237
|
-
client(raw, path) {
|
|
238
|
-
if (raw === undefined)
|
|
239
|
-
return undefined;
|
|
240
|
-
if (!isRecord(raw)) {
|
|
241
|
-
return this.fail(path, 'must be a mapping: source: vault-pki, value: <role path> and commonName');
|
|
242
|
-
}
|
|
243
|
-
this.unknownKeys(raw, path, ['source', 'value', 'commonName']);
|
|
244
|
-
this.sourceIs(raw, path, 'vault-pki');
|
|
245
|
-
const vaultPki = this.vaultPath(raw.value, [...path, 'value'], 'pki/issue/<role>');
|
|
246
|
-
const commonName = this.text(raw.commonName, [...path, 'commonName'], { required: true });
|
|
247
|
-
return vaultPki === undefined || commonName === undefined ? undefined : { vaultPki, commonName };
|
|
248
|
-
}
|
|
249
|
-
// A group of fields filled by one Vault lease names its kind as the source.
|
|
250
|
-
sourceIs(raw, path, expected) {
|
|
251
|
-
if (raw.source !== expected) {
|
|
252
|
-
this.add([...path, 'source'], `must be ${expected}`, raw.source !== undefined);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// An API path in Vault, e.g. "pki/issue/migrator".
|
|
256
|
-
vaultPath(raw, path, example) {
|
|
257
|
-
if (raw === undefined)
|
|
258
|
-
return this.fail(path, 'is required');
|
|
259
|
-
return typeof raw === 'string' && vaultRolePattern.test(raw)
|
|
260
|
-
? raw
|
|
261
|
-
: this.fail(path, `must be a Vault API path such as ${example}`);
|
|
262
|
-
}
|
|
263
201
|
// A literal text setting.
|
|
264
202
|
text(raw, path, options) {
|
|
265
203
|
if (raw === undefined) {
|
|
@@ -17,20 +17,6 @@ export async function resolveEnvironment(env, ctx) {
|
|
|
17
17
|
interactive: ctx.interactive,
|
|
18
18
|
signal: ctx.signal,
|
|
19
19
|
}, registry);
|
|
20
|
-
const groupContext = (field) => {
|
|
21
|
-
if (!ctx.groups)
|
|
22
|
-
throw fail(field, 'no resolver for Vault credentials or certificates');
|
|
23
|
-
return {
|
|
24
|
-
groups: ctx.groups,
|
|
25
|
-
ctx: {
|
|
26
|
-
envName: env.name,
|
|
27
|
-
field,
|
|
28
|
-
configDir: env.dir,
|
|
29
|
-
interactive: ctx.interactive,
|
|
30
|
-
signal: ctx.signal,
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
20
|
// Text from a file or a prompt often carries a stray newline or space.
|
|
35
21
|
const checked = async (field, value, rule) => {
|
|
36
22
|
const text = (await resolve(field, value)).trim();
|
|
@@ -59,48 +45,20 @@ export async function resolveEnvironment(env, ctx) {
|
|
|
59
45
|
const schema = env.schema === undefined
|
|
60
46
|
? defaultSchemaName
|
|
61
47
|
: await checked('schema', env.schema, (v) => identifierError('Schema', v));
|
|
62
|
-
|
|
48
|
+
const username = await checked('username', env.username, (v) => identifierError('Username', v));
|
|
63
49
|
let password;
|
|
64
|
-
if (env.
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (userMessage)
|
|
71
|
-
throw fail('credentials', `Vault issued an invalid username: ${userMessage}`);
|
|
72
|
-
const passwordMessage = passwordError(issued.password);
|
|
73
|
-
if (passwordMessage)
|
|
74
|
-
throw fail('credentials', `Vault issued an invalid password`);
|
|
75
|
-
password = issued.password;
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
username = await checked('username', env.username, (v) => identifierError('Username', v));
|
|
79
|
-
if (env.auth === 'password' && env.password !== undefined) {
|
|
80
|
-
const text = await resolve('password', env.password);
|
|
81
|
-
const message = passwordError(text);
|
|
82
|
-
if (message)
|
|
83
|
-
throw fail('password', message);
|
|
84
|
-
password = text;
|
|
85
|
-
}
|
|
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;
|
|
86
56
|
}
|
|
87
57
|
const tls = { mode: env.tls.mode };
|
|
88
58
|
// With TLS off these are never used, so they are not looked up (or prompted for).
|
|
89
59
|
if (env.tls.mode !== 'disable') {
|
|
90
60
|
if (env.tls.ca !== undefined)
|
|
91
61
|
tls.ca = await pem('tls.ca', env.tls.ca);
|
|
92
|
-
if (env.tls.client) {
|
|
93
|
-
const { ctx: clientCtx, groups } = groupContext('tls.client');
|
|
94
|
-
const issued = await groups.clientCertificate(env.tls.client.vaultPki, env.tls.client.commonName, clientCtx);
|
|
95
|
-
if (!issued.cert.includes('-----BEGIN') || !issued.key.includes('-----BEGIN')) {
|
|
96
|
-
throw fail('tls.client', 'Vault did not issue PEM text');
|
|
97
|
-
}
|
|
98
|
-
tls.cert = issued.cert;
|
|
99
|
-
tls.key = issued.key;
|
|
100
|
-
// The issuing CA stands in for a ca that was not written in the file.
|
|
101
|
-
if (tls.ca === undefined && issued.ca)
|
|
102
|
-
tls.ca = issued.ca;
|
|
103
|
-
}
|
|
104
62
|
if (env.tls.cert !== undefined)
|
|
105
63
|
tls.cert = await pem('tls.cert', env.tls.cert);
|
|
106
64
|
if (env.tls.key !== undefined)
|
|
@@ -143,19 +101,9 @@ export function describeEnvironment(env, registry = defaultRegistry()) {
|
|
|
143
101
|
add('port', env.port);
|
|
144
102
|
add('database', env.database);
|
|
145
103
|
add('schema', env.schema);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
add('username', env.username);
|
|
152
|
-
if (env.auth === 'password')
|
|
153
|
-
add('password', env.password);
|
|
154
|
-
}
|
|
155
|
-
if (env.tls.client) {
|
|
156
|
-
labels['tls.cert'] = `vault pki: ${env.tls.client.vaultPki}`;
|
|
157
|
-
labels['tls.key'] = labels['tls.cert'];
|
|
158
|
-
}
|
|
104
|
+
add('username', env.username);
|
|
105
|
+
if (env.auth === 'password')
|
|
106
|
+
add('password', env.password);
|
|
159
107
|
add('tls.ca', env.tls.ca);
|
|
160
108
|
add('tls.cert', env.tls.cert);
|
|
161
109
|
add('tls.key', env.tls.key);
|
|
@@ -17,7 +17,7 @@ export const createRegistry = (resolvers) => new Map(resolvers.map((resolver) =>
|
|
|
17
17
|
// `prompter` enables `prompt` values and `store` `stored` ones; without them those
|
|
18
18
|
// fail with "no resolver".
|
|
19
19
|
export const defaultRegistry = (environment, prompter, store,
|
|
20
|
-
// Share one Vault client (its token
|
|
20
|
+
// Share one Vault client (its login token) with whoever else reads from Vault.
|
|
21
21
|
vault = createVaultClient({ env: environment })) => createRegistry([
|
|
22
22
|
literalResolver,
|
|
23
23
|
createEnvResolver(environment),
|
|
@@ -115,11 +115,11 @@ export function createPromptResolver(prompter) {
|
|
|
115
115
|
return answer;
|
|
116
116
|
},
|
|
117
117
|
confirm(ctx) {
|
|
118
|
-
const write = ctx.purpose === 'write';
|
|
118
|
+
const write = ctx.purpose === 'write' || ctx.purpose === 'drop';
|
|
119
119
|
const remembered = write ? undefined : confirmed.get(ctx.envName);
|
|
120
120
|
if (remembered)
|
|
121
121
|
return remembered;
|
|
122
|
-
const field = write ? 'write' : 'confirm';
|
|
122
|
+
const field = ctx.purpose === 'drop' ? 'drop' : write ? 'write' : 'confirm';
|
|
123
123
|
if (!ctx.interactive)
|
|
124
124
|
return Promise.reject(new PromptRequiredError(ctx.envName, field));
|
|
125
125
|
const challenge = challengeWords();
|
|
@@ -260,88 +260,6 @@ export function createVaultClient(options = {}) {
|
|
|
260
260
|
throw fail(ctx, `unexpected response from Vault for ${metadata}`);
|
|
261
261
|
},
|
|
262
262
|
};
|
|
263
|
-
const leases = new Map();
|
|
264
|
-
const issued = new Map();
|
|
265
|
-
const text = (ctx, data, key, path) => {
|
|
266
|
-
const value = data[key];
|
|
267
|
-
if (typeof value !== 'string' || value === '') {
|
|
268
|
-
throw fail(ctx, `key ${key} not in the answer of ${path}`);
|
|
269
|
-
}
|
|
270
|
-
return value;
|
|
271
|
-
};
|
|
272
|
-
// Asks Vault for something new once per environment and path (every time when `reuse` is false),
|
|
273
|
-
// and remembers its lease so release() can revoke it. A lease that has run out is replaced.
|
|
274
|
-
const issue = async (ctx, path, request, read, reuse = true) => {
|
|
275
|
-
const cacheKey = `${ctx.envName}\0${path}`;
|
|
276
|
-
const cached = reuse ? issued.get(cacheKey) : undefined;
|
|
277
|
-
if (cached) {
|
|
278
|
-
const entry = await cached;
|
|
279
|
-
if (entry.expiresAt === undefined || now() < entry.expiresAt)
|
|
280
|
-
return entry.values;
|
|
281
|
-
if (issued.get(cacheKey) === cached)
|
|
282
|
-
issued.delete(cacheKey);
|
|
283
|
-
}
|
|
284
|
-
const pending = (async () => {
|
|
285
|
-
const body = await call(ctx, path, request, 'role');
|
|
286
|
-
const values = read((body.data ?? {}));
|
|
287
|
-
const leaseId = typeof body.lease_id === 'string' ? body.lease_id : '';
|
|
288
|
-
const duration = typeof body.lease_duration === 'number' ? body.lease_duration : 0;
|
|
289
|
-
const expiresAt = duration > 0 ? now() + duration * 1000 - expiryMarginMs : undefined;
|
|
290
|
-
if (leaseId) {
|
|
291
|
-
leases.set(ctx.envName, [...(leases.get(ctx.envName) ?? []), { id: leaseId, expiresAt }]);
|
|
292
|
-
}
|
|
293
|
-
return { values, expiresAt };
|
|
294
|
-
})();
|
|
295
|
-
if (reuse) {
|
|
296
|
-
issued.set(cacheKey, pending);
|
|
297
|
-
pending.catch(() => {
|
|
298
|
-
if (issued.get(cacheKey) === pending)
|
|
299
|
-
issued.delete(cacheKey);
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
return (await pending).values;
|
|
303
|
-
};
|
|
304
|
-
const revoke = async (envName) => {
|
|
305
|
-
const held = leases.get(envName) ?? [];
|
|
306
|
-
leases.delete(envName);
|
|
307
|
-
for (const key of [...issued.keys()])
|
|
308
|
-
if (key.startsWith(`${envName}\0`))
|
|
309
|
-
issued.delete(key);
|
|
310
|
-
const ctx = { envName, field: 'lease', configDir: '', interactive: false };
|
|
311
|
-
for (const lease of held) {
|
|
312
|
-
try {
|
|
313
|
-
await authed(ctx, 'sys/leases/revoke', { method: 'PUT', body: { lease_id: lease.id } });
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// Best effort: a lease that cannot be revoked still ends when its TTL does.
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
const groups = {
|
|
321
|
-
async databaseCredentials(path, ctx) {
|
|
322
|
-
// Never reused: a password is asked for again every time it is needed.
|
|
323
|
-
const values = await issue(ctx, path, {}, (data) => ({
|
|
324
|
-
username: text(ctx, data, 'username', path),
|
|
325
|
-
password: text(ctx, data, 'password', path),
|
|
326
|
-
}), false);
|
|
327
|
-
return { username: values.username, password: values.password };
|
|
328
|
-
},
|
|
329
|
-
async clientCertificate(path, commonName, ctx) {
|
|
330
|
-
const values = await issue(ctx, path, { method: 'POST', body: { common_name: commonName } }, (data) => ({
|
|
331
|
-
cert: text(ctx, data, 'certificate', path),
|
|
332
|
-
key: text(ctx, data, 'private_key', path),
|
|
333
|
-
...(typeof data.issuing_ca === 'string' && data.issuing_ca !== ''
|
|
334
|
-
? { ca: data.issuing_ca }
|
|
335
|
-
: {}),
|
|
336
|
-
}));
|
|
337
|
-
return { cert: values.cert, key: values.key, ...(values.ca ? { ca: values.ca } : {}) };
|
|
338
|
-
},
|
|
339
|
-
release: revoke,
|
|
340
|
-
releaseAll: async () => {
|
|
341
|
-
for (const envName of [...leases.keys()])
|
|
342
|
-
await revoke(envName);
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
263
|
const resolver = {
|
|
346
264
|
kind: 'vault',
|
|
347
265
|
async resolve(ref, ctx) {
|
|
@@ -360,6 +278,6 @@ export function createVaultClient(options = {}) {
|
|
|
360
278
|
},
|
|
361
279
|
describe: ({ vault }) => `vault: ${vault}`,
|
|
362
280
|
};
|
|
363
|
-
return { resolver,
|
|
281
|
+
return { resolver, kv };
|
|
364
282
|
}
|
|
365
283
|
export const createVaultResolver = (options = {}) => createVaultClient(options).resolver;
|
package/dist/db/store.js
CHANGED
|
@@ -82,6 +82,18 @@ const migrations = [
|
|
|
82
82
|
error TEXT NULL
|
|
83
83
|
);
|
|
84
84
|
CREATE INDEX history_env ON history (env_name, id)`,
|
|
85
|
+
// Queries kept by name, per environment, for the web UI.
|
|
86
|
+
`CREATE TABLE saved_queries (
|
|
87
|
+
id INTEGER PRIMARY KEY,
|
|
88
|
+
env_name TEXT NOT NULL,
|
|
89
|
+
name TEXT NOT NULL,
|
|
90
|
+
folder TEXT NOT NULL DEFAULT '',
|
|
91
|
+
sql TEXT NOT NULL,
|
|
92
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
93
|
+
created_at TEXT NOT NULL,
|
|
94
|
+
updated_at TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX saved_queries_env ON saved_queries (env_name)`,
|
|
85
97
|
];
|
|
86
98
|
export const schemaVersion = migrations.length;
|
|
87
99
|
function migrate(db) {
|
|
@@ -102,6 +114,16 @@ function migrate(db) {
|
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
116
|
}
|
|
117
|
+
const toSaved = (row) => ({
|
|
118
|
+
id: row.id,
|
|
119
|
+
envName: row.env_name,
|
|
120
|
+
name: row.name,
|
|
121
|
+
folder: row.folder,
|
|
122
|
+
sql: row.sql,
|
|
123
|
+
pinned: row.pinned === 1,
|
|
124
|
+
createdAt: row.created_at,
|
|
125
|
+
updatedAt: row.updated_at,
|
|
126
|
+
});
|
|
105
127
|
const toHistory = (row) => ({
|
|
106
128
|
id: row.id,
|
|
107
129
|
at: row.at,
|
|
@@ -203,6 +225,14 @@ export function openStore(dir) {
|
|
|
203
225
|
const historyByEnv = database.prepare(`SELECT * FROM history WHERE id IN (
|
|
204
226
|
SELECT MAX(id) FROM history WHERE env_name = ? GROUP BY sql
|
|
205
227
|
) ORDER BY id DESC LIMIT ?`);
|
|
228
|
+
const insertSaved = database.prepare(`INSERT INTO saved_queries (env_name, name, folder, sql, pinned, created_at, updated_at)
|
|
229
|
+
VALUES (:envName, :name, :folder, :sql, :pinned, :now, :now)`);
|
|
230
|
+
const selectSaved = database.prepare('SELECT * FROM saved_queries WHERE id = ?');
|
|
231
|
+
const savedByEnv = database.prepare(`SELECT * FROM saved_queries WHERE env_name = ?
|
|
232
|
+
ORDER BY pinned DESC, folder COLLATE NOCASE, name COLLATE NOCASE, id`);
|
|
233
|
+
const changeSaved = database.prepare(`UPDATE saved_queries SET name = :name, folder = :folder, sql = :sql, pinned = :pinned,
|
|
234
|
+
updated_at = :now WHERE id = :id`);
|
|
235
|
+
const removeSaved = database.prepare('DELETE FROM saved_queries WHERE id = ?');
|
|
206
236
|
const selectSetting = database.prepare('SELECT value FROM settings WHERE key = ?');
|
|
207
237
|
const upsertSetting = database.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value');
|
|
208
238
|
const selectSecret = database.prepare('SELECT value FROM secrets WHERE key = ?');
|
|
@@ -246,6 +276,33 @@ export function openStore(dir) {
|
|
|
246
276
|
return toHistory(selectHistory.get(Number(lastInsertRowid)));
|
|
247
277
|
},
|
|
248
278
|
historyFor: (envName, limit = 200) => historyByEnv.all(envName, limit).map(toHistory),
|
|
279
|
+
savedQueries: (envName) => savedByEnv.all(envName).map(toSaved),
|
|
280
|
+
saveQuery: (input) => {
|
|
281
|
+
const { lastInsertRowid } = insertSaved.run({
|
|
282
|
+
envName: input.envName,
|
|
283
|
+
name: input.name,
|
|
284
|
+
folder: input.folder ?? '',
|
|
285
|
+
sql: input.sql,
|
|
286
|
+
pinned: input.pinned ? 1 : 0,
|
|
287
|
+
now: now(),
|
|
288
|
+
});
|
|
289
|
+
return toSaved(selectSaved.get(Number(lastInsertRowid)));
|
|
290
|
+
},
|
|
291
|
+
updateSavedQuery: (id, changes) => {
|
|
292
|
+
const row = selectSaved.get(id);
|
|
293
|
+
if (!row)
|
|
294
|
+
return undefined;
|
|
295
|
+
changeSaved.run({
|
|
296
|
+
id,
|
|
297
|
+
name: changes.name ?? row.name,
|
|
298
|
+
folder: changes.folder ?? row.folder,
|
|
299
|
+
sql: changes.sql ?? row.sql,
|
|
300
|
+
pinned: (changes.pinned ?? row.pinned === 1) ? 1 : 0,
|
|
301
|
+
now: now(),
|
|
302
|
+
});
|
|
303
|
+
return toSaved(selectSaved.get(id));
|
|
304
|
+
},
|
|
305
|
+
deleteSavedQuery: (id) => Number(removeSaved.run(id).changes) > 0,
|
|
249
306
|
getSetting: (key) => selectSetting.get(key)?.value,
|
|
250
307
|
setSetting: (key, value) => {
|
|
251
308
|
upsertSetting.run(key, value);
|
package/dist/environments.js
CHANGED
|
@@ -34,8 +34,10 @@ export function listEnvironments(services) {
|
|
|
34
34
|
tier: config.tier,
|
|
35
35
|
protected: config.protected,
|
|
36
36
|
readOnly: config.readOnly,
|
|
37
|
+
seeder: config.seeder,
|
|
38
|
+
designer: config.designer,
|
|
37
39
|
tlsMode: config.tls.mode,
|
|
38
|
-
user:
|
|
40
|
+
user: display(config.username),
|
|
39
41
|
host: display(config.host),
|
|
40
42
|
port: display(config.port),
|
|
41
43
|
database: display(config.database),
|
|
@@ -49,6 +51,8 @@ export function listEnvironments(services) {
|
|
|
49
51
|
tier: 'local',
|
|
50
52
|
protected: false,
|
|
51
53
|
readOnly: false,
|
|
54
|
+
seeder: true,
|
|
55
|
+
designer: true,
|
|
52
56
|
tlsMode: connection.ssl ? 'require' : 'disable',
|
|
53
57
|
user: connection.username,
|
|
54
58
|
host: connection.host,
|
|
@@ -69,7 +73,6 @@ options = {}) {
|
|
|
69
73
|
return resolveEnvironment(config, {
|
|
70
74
|
interactive: options.interactive ?? true,
|
|
71
75
|
registry: services.resolvers,
|
|
72
|
-
groups: services.groups,
|
|
73
76
|
signal: options.signal,
|
|
74
77
|
});
|
|
75
78
|
}
|