@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,84 @@
1
+ // What the app does before the first screen is usable: read the shared config (a directory, or
2
+ // Vault). The splash screen shows each step as it happens. A step that cannot finish is reported,
3
+ // never thrown: local connections work without any of it.
4
+ import { environmentsVariable, loadConfig } from './config/load.js';
5
+ import { vaultPrefix } from './config/source.js';
6
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
7
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
8
+ export function createStartup(options) {
9
+ const { services } = options;
10
+ const load = options.load ?? loadConfig;
11
+ let steps = [];
12
+ let finished = false;
13
+ let running;
14
+ const listeners = new Set();
15
+ const emit = (next) => {
16
+ steps = next;
17
+ for (const listener of listeners)
18
+ listener();
19
+ };
20
+ const begin = (id, label) => {
21
+ emit([...steps, { id, label, state: 'running', startedAt: Date.now() }]);
22
+ };
23
+ const change = (id, patch) => {
24
+ emit(steps.map((step) => (step.id === id ? { ...step, ...patch } : step)));
25
+ };
26
+ async function readConfig(base) {
27
+ const inVault = base.startsWith(vaultPrefix);
28
+ begin('config', inVault
29
+ ? `Loading the config from Vault (${base.slice(vaultPrefix.length)})`
30
+ : `Loading the config from ${base}`);
31
+ try {
32
+ const result = await load({
33
+ base,
34
+ env: options.env,
35
+ vault: options.vault,
36
+ onProgress: (detail) => {
37
+ change('config', { detail: `reading ${detail}` });
38
+ },
39
+ });
40
+ services.config = result.config;
41
+ services.configError = result.error;
42
+ const problems = result.error?.problems.length ?? 0;
43
+ if (!result.config) {
44
+ change('config', {
45
+ state: 'failed',
46
+ detail: result.error?.problems[0]?.message ?? 'nothing could be read',
47
+ });
48
+ }
49
+ else {
50
+ const found = plural(result.config.environments.length, 'environment');
51
+ change('config', {
52
+ state: problems > 0 ? 'warn' : 'done',
53
+ detail: problems > 0 ? `${found}, ${plural(problems, 'problem')}` : found,
54
+ });
55
+ }
56
+ return result;
57
+ }
58
+ catch (error) {
59
+ change('config', { state: 'failed', detail: messageOf(error) });
60
+ return {};
61
+ }
62
+ }
63
+ async function run() {
64
+ const base = options.base || (options.env ?? process.env)[environmentsVariable];
65
+ if (base)
66
+ await readConfig(base);
67
+ finished = true;
68
+ emit([...steps]);
69
+ }
70
+ return {
71
+ snapshot: () => steps,
72
+ subscribe(listener) {
73
+ listeners.add(listener);
74
+ return () => {
75
+ listeners.delete(listener);
76
+ };
77
+ },
78
+ start: () => (running ??= run().catch(() => {
79
+ finished = true;
80
+ emit([...steps]);
81
+ })),
82
+ finished: () => finished,
83
+ };
84
+ }
@@ -0,0 +1,355 @@
1
+ // The HTTP server behind `psql-explorer web`: a small JSON API over the same services the TUI and
2
+ // the commands use, plus the built web UI (web/dist). It only listens on the loopback interface
3
+ // and refuses requests whose Host is not loopback, so a web page cannot reach it by DNS rebinding.
4
+ import { spawn } from 'node:child_process';
5
+ import { createServer } from 'node:http';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { extname, join, normalize, resolve, sep } from 'node:path';
8
+ import { ResolveError } from '../config/resolvers/index.js';
9
+ import { addressOf, findEnvironment, forgetAnswers, listEnvironments } from '../environments.js';
10
+ import { connectEntry, LoginError } from '../explorer/connect-entry.js';
11
+ import { describeCatalog, describeErd, describeFunction, describeRelation, describeSequence, describeType, listObjects, listSchemas, serverInfo, } from '../explorer/catalog.js';
12
+ import { explainQuery } from '../explorer/explain.js';
13
+ import { browseRows, runPage } from '../explorer/rows.js';
14
+ import { describeQueryError } from '../explorer/session.js';
15
+ export const DEFAULT_WEB_PORT = 7878;
16
+ const MAX_BODY_BYTES = 1_000_000;
17
+ const contentTypes = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.svg': 'image/svg+xml',
23
+ '.png': 'image/png',
24
+ '.ico': 'image/x-icon',
25
+ '.woff2': 'font/woff2',
26
+ };
27
+ /** A request that cannot be answered as sent: the status says why. */
28
+ class HttpError extends Error {
29
+ status;
30
+ constructor(status, message) {
31
+ super(message);
32
+ this.status = status;
33
+ }
34
+ }
35
+ const sendJson = (res, status, body) => {
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
37
+ res.end(JSON.stringify(body));
38
+ };
39
+ const requiredParam = (url, key) => {
40
+ const value = url.searchParams.get(key);
41
+ if (!value)
42
+ throw new HttpError(400, `${url.pathname.split('/').pop()} needs ?${key}=`);
43
+ return value;
44
+ };
45
+ const isLoopbackHost = (host) => {
46
+ if (!host)
47
+ return false;
48
+ const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
49
+ return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
50
+ };
51
+ async function readJson(req) {
52
+ if (!req.headers['content-type']?.startsWith('application/json')) {
53
+ throw new HttpError(415, 'send application/json');
54
+ }
55
+ const chunks = [];
56
+ let size = 0;
57
+ for await (const chunk of req) {
58
+ size += chunk.length;
59
+ if (size > MAX_BODY_BYTES)
60
+ throw new HttpError(413, 'request body is too large');
61
+ chunks.push(chunk);
62
+ }
63
+ try {
64
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
65
+ }
66
+ catch {
67
+ throw new HttpError(400, 'the body is not valid JSON');
68
+ }
69
+ }
70
+ export function createWebServer(options) {
71
+ const { services } = options;
72
+ const staticDir = options.staticDir ? resolve(options.staticDir) : undefined;
73
+ const environmentNamed = (name) => {
74
+ const entry = findEnvironment(services, name);
75
+ if (!entry)
76
+ throw new HttpError(404, `no environment named "${name}"`);
77
+ return entry;
78
+ };
79
+ // Protected environments the person has switched to read write, by entry key. In memory only, so
80
+ // a restart is read only again.
81
+ const writable = new Set();
82
+ // One connection per request keeps the scaffold simple; pooling comes with the real UI.
83
+ async function withSession(name, work, signal) {
84
+ const entry = environmentNamed(name);
85
+ let session;
86
+ try {
87
+ session = await connectEntry(entry, services, {
88
+ signal,
89
+ ...(writable.has(entry.key) ? { readOnly: false } : {}),
90
+ });
91
+ }
92
+ catch (error) {
93
+ // What was typed was refused, so ask again next time instead of remembering it.
94
+ if (error instanceof LoginError)
95
+ forgetAnswers(entry, services);
96
+ throw error;
97
+ }
98
+ try {
99
+ return await work(session);
100
+ }
101
+ finally {
102
+ await session.close();
103
+ await services.groups?.release(entry.name);
104
+ }
105
+ }
106
+ async function api(req, url, signal) {
107
+ const withSessionFor = (name, work) => withSession(name, work, signal);
108
+ const parts = url.pathname.split('/').slice(2).map(decodeURIComponent);
109
+ const [resource, name, sub] = parts;
110
+ if (resource === 'prompt' && !name && req.method === 'GET') {
111
+ const pending = services.prompts?.current();
112
+ return {
113
+ prompt: pending
114
+ ? {
115
+ envName: pending.envName,
116
+ field: pending.field,
117
+ masked: pending.masked,
118
+ ...(pending.challenge === undefined ? {} : { challenge: pending.challenge }),
119
+ }
120
+ : null,
121
+ };
122
+ }
123
+ if (resource === 'prompt' && !name && req.method === 'POST') {
124
+ const body = (await readJson(req));
125
+ const pending = services.prompts?.current();
126
+ // The question may have been answered elsewhere since the page last looked.
127
+ if (!pending || pending.envName !== body?.envName || pending.field !== body?.field) {
128
+ throw new HttpError(409, 'that question is no longer waiting for an answer');
129
+ }
130
+ if (body.value === null)
131
+ pending.cancel();
132
+ else if (typeof body.value === 'string' && body.value !== '')
133
+ pending.submit(body.value);
134
+ else
135
+ throw new HttpError(400, 'send {"value": "..."} or {"value": null} to cancel');
136
+ return {};
137
+ }
138
+ if (resource === 'envs' && !name && req.method === 'GET') {
139
+ return {
140
+ environments: listEnvironments(services).map((entry) => ({
141
+ name: entry.name,
142
+ source: entry.source,
143
+ tier: entry.tier,
144
+ protected: entry.protected,
145
+ readOnly: entry.readOnly,
146
+ address: addressOf(entry),
147
+ tls: entry.tlsMode,
148
+ })),
149
+ error: services.configError?.message,
150
+ };
151
+ }
152
+ if (resource === 'envs' && name && sub === 'write' && (req.method === 'GET' || req.method === 'POST')) {
153
+ const entry = environmentNamed(name);
154
+ // Only a protected environment that is read only has anything to switch.
155
+ const available = entry.protected && entry.readOnly;
156
+ if (req.method === 'POST') {
157
+ const body = (await readJson(req));
158
+ if (typeof body?.enabled !== 'boolean')
159
+ throw new HttpError(400, 'send {"enabled": true|false}');
160
+ if (!available)
161
+ throw new HttpError(400, `${entry.name} is not a protected read only environment`);
162
+ if (!body.enabled)
163
+ writable.delete(entry.key);
164
+ else if (!writable.has(entry.key)) {
165
+ // Always asked, whatever `confirm` says: this is the step that allows changing data.
166
+ const prompt = services.resolvers.get('prompt');
167
+ if (!prompt)
168
+ throw new HttpError(400, 'there is no way to ask for confirmation');
169
+ try {
170
+ await prompt.confirm({ envName: entry.name, interactive: true, signal, purpose: 'write' });
171
+ }
172
+ catch (error) {
173
+ if (error instanceof ResolveError)
174
+ throw new HttpError(400, error.message);
175
+ throw error;
176
+ }
177
+ writable.add(entry.key);
178
+ }
179
+ }
180
+ return { available, enabled: writable.has(entry.key) };
181
+ }
182
+ if (resource === 'envs' && name && sub === 'schemas' && req.method === 'GET') {
183
+ return withSessionFor(name, (session) => listSchemas(session));
184
+ }
185
+ if (resource === 'envs' && name && sub === 'erd' && req.method === 'GET') {
186
+ const schema = requiredParam(url, 'schema');
187
+ return withSessionFor(name, (session) => describeErd(session, schema));
188
+ }
189
+ if (resource === 'envs' && name && sub === 'catalog' && req.method === 'GET') {
190
+ return withSessionFor(name, (session) => describeCatalog(session));
191
+ }
192
+ if (resource === 'envs' && name && sub === 'info' && req.method === 'GET') {
193
+ return withSessionFor(name, (session) => serverInfo(session));
194
+ }
195
+ if (resource === 'envs' && name && sub === 'objects' && req.method === 'GET') {
196
+ const schema = requiredParam(url, 'schema');
197
+ return withSessionFor(name, (session) => listObjects(session, schema));
198
+ }
199
+ if (resource === 'envs' && name && sub === 'object' && req.method === 'GET') {
200
+ const schema = requiredParam(url, 'schema');
201
+ const object = requiredParam(url, 'name');
202
+ const kind = requiredParam(url, 'kind');
203
+ const found = await withSessionFor(name, (session) => {
204
+ if (kind === 'table')
205
+ return describeRelation(session, { schema, name: object });
206
+ if (kind === 'function') {
207
+ return describeFunction(session, { schema, name: object, args: url.searchParams.get('args') ?? '' });
208
+ }
209
+ if (kind === 'sequence')
210
+ return describeSequence(session, { schema, name: object });
211
+ if (kind === 'type')
212
+ return describeType(session, { schema, name: object });
213
+ throw new HttpError(400, `unknown kind "${kind}"`);
214
+ });
215
+ if (!found)
216
+ throw new HttpError(404, `no ${kind} ${schema}.${object}`);
217
+ return found;
218
+ }
219
+ if (resource === 'envs' && name && sub === 'rows' && req.method === 'GET') {
220
+ const schema = requiredParam(url, 'schema');
221
+ const table = requiredParam(url, 'table');
222
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 100, 1), 1000);
223
+ const offset = Math.max(Number(url.searchParams.get('offset')) || 0, 0);
224
+ try {
225
+ return await withSessionFor(name, (session) => browseRows(session, { schema, name: table }, { limit, offset }));
226
+ }
227
+ catch (error) {
228
+ if (error instanceof HttpError)
229
+ throw error;
230
+ throw new HttpError(400, describeQueryError(error));
231
+ }
232
+ }
233
+ if (resource === 'envs' && name && sub === 'query' && req.method === 'POST') {
234
+ const body = (await readJson(req));
235
+ if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
236
+ throw new HttpError(400, 'send {"sql": "..."}');
237
+ }
238
+ const sql = body.sql;
239
+ const limit = Math.min(Math.max(Number(body.limit) || 100, 1), 1000);
240
+ const offset = Math.max(Math.floor(Number(body.offset)) || 0, 0);
241
+ try {
242
+ return await withSessionFor(name, (session) => runPage(session, sql, { limit, offset }));
243
+ }
244
+ catch (error) {
245
+ if (error instanceof HttpError)
246
+ throw error;
247
+ throw new HttpError(400, describeQueryError(error));
248
+ }
249
+ }
250
+ if (resource === 'envs' && name && sub === 'explain' && req.method === 'POST') {
251
+ const body = (await readJson(req));
252
+ if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
253
+ throw new HttpError(400, 'send {"sql": "..."}');
254
+ }
255
+ const sql = body.sql;
256
+ try {
257
+ return { plan: await withSessionFor(name, (session) => explainQuery(session, sql)) };
258
+ }
259
+ catch (error) {
260
+ if (error instanceof HttpError)
261
+ throw error;
262
+ throw new HttpError(400, describeQueryError(error));
263
+ }
264
+ }
265
+ throw new HttpError(404, 'not found');
266
+ }
267
+ async function serveStatic(res, pathname) {
268
+ if (!staticDir)
269
+ throw new HttpError(404, 'the web UI is not built; run npm run build:web');
270
+ const file = normalize(join(staticDir, pathname === '/' ? 'index.html' : pathname));
271
+ if (file !== staticDir && !file.startsWith(staticDir + sep))
272
+ throw new HttpError(403, 'forbidden');
273
+ let data;
274
+ let type = contentTypes[extname(file)] ?? 'application/octet-stream';
275
+ try {
276
+ data = await readFile(file);
277
+ }
278
+ catch {
279
+ // Not a file: the single page app handles the route itself.
280
+ if (extname(pathname))
281
+ throw new HttpError(404, 'not found');
282
+ data = await readFile(join(staticDir, 'index.html')).catch(() => {
283
+ throw new HttpError(404, 'the web UI is not built; run npm run build:web');
284
+ });
285
+ type = contentTypes['.html'];
286
+ }
287
+ res.writeHead(200, { 'content-type': type });
288
+ res.end(data);
289
+ }
290
+ return createServer((req, res) => {
291
+ void (async () => {
292
+ try {
293
+ if (!isLoopbackHost(req.headers.host))
294
+ throw new HttpError(403, 'forbidden host');
295
+ const url = new URL(req.url ?? '/', 'http://localhost');
296
+ if (url.pathname.startsWith('/api/')) {
297
+ // A request the browser gave up on stops waiting for answers to its questions.
298
+ const abort = new AbortController();
299
+ res.on('close', () => abort.abort());
300
+ sendJson(res, 200, await api(req, url, abort.signal));
301
+ }
302
+ else if (req.method === 'GET' || req.method === 'HEAD')
303
+ await serveStatic(res, url.pathname);
304
+ else
305
+ throw new HttpError(405, 'method not allowed');
306
+ }
307
+ catch (error) {
308
+ if (error instanceof HttpError) {
309
+ sendJson(res, error.status, { error: error.message });
310
+ }
311
+ else {
312
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
313
+ }
314
+ }
315
+ })();
316
+ });
317
+ }
318
+ const PORT_ATTEMPTS = 100;
319
+ /**
320
+ * Starts listening on the loopback interface; resolves with the address once it is up. When the
321
+ * port is taken the next free one is used, so the caller compares the result with what it asked for.
322
+ */
323
+ export async function listen(server, port) {
324
+ for (let candidate = port; candidate < port + PORT_ATTEMPTS && candidate <= 65535; candidate++) {
325
+ try {
326
+ await listenOn(server, candidate);
327
+ return `http://127.0.0.1:${candidate}`;
328
+ }
329
+ catch (error) {
330
+ if (error.code !== 'EADDRINUSE')
331
+ throw error;
332
+ }
333
+ }
334
+ throw new Error(`no free port found from ${port} to ${port + PORT_ATTEMPTS - 1}`);
335
+ }
336
+ function listenOn(server, port) {
337
+ return new Promise((resolveListen, reject) => {
338
+ server.once('error', reject);
339
+ server.listen(port, '127.0.0.1', () => {
340
+ server.off('error', reject);
341
+ resolveListen();
342
+ });
343
+ });
344
+ }
345
+ /** Opens the address in the default browser; failing to is not an error, the address is printed. */
346
+ export function openBrowser(address) {
347
+ const [command, args] = process.platform === 'darwin'
348
+ ? ['open', [address]]
349
+ : process.platform === 'win32'
350
+ ? ['cmd', ['/c', 'start', '', address]]
351
+ : ['xdg-open', [address]];
352
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
353
+ child.on('error', () => { });
354
+ child.unref();
355
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
- "name": "@kazurolabs/psql-explorer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@kazurolabs/psql-explorer",
3
+ "version": "0.1.0",
4
+ "description": "Explore your Postgres databases from the terminal, with credentials from Vault",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "psql-explorer": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "web/dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.13"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc && npm run build:web",
24
+ "dev": "tsx scripts/dev.mjs",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc -p tsconfig.test.json",
27
+ "lint": "npm run typecheck && eslint . && prettier --check .",
28
+ "format": "prettier --write .",
29
+ "prepublishOnly": "npm run install:web && npm run build",
30
+ "dev:reset": "tsx scripts/dev.mjs --reset",
31
+ "dev:plain": "tsx src/cli.tsx",
32
+ "build:web": "npm --prefix web run build",
33
+ "dev:web": "tsx scripts/dev-web.mjs",
34
+ "dev:web:reset": "tsx scripts/dev-web.mjs --reset",
35
+ "install:web": "npm --prefix web install",
36
+ "dev:vite": "npm --prefix web run dev"
37
+ },
38
+ "dependencies": {
39
+ "ink": "^8.0.0",
40
+ "ink-big-text": "^2.0.0",
41
+ "ink-gradient": "^4.0.1",
42
+ "ink-spinner": "^5.0.0",
43
+ "ink-text-input": "^6.0.0",
44
+ "meow": "^14.1.0",
45
+ "pg": "^8.23.1",
46
+ "pg-cursor": "^2.22.1",
47
+ "react": "^19.3.1",
48
+ "string-width": "^8.3.0",
49
+ "yaml": "^2.9.1"
50
+ },
51
+ "devDependencies": {
52
+ "@eslint/js": "^10.0.1",
53
+ "@types/node": "^22.20.5",
54
+ "@types/pg": "^8.23.1",
55
+ "@types/pg-cursor": "^2.7.2",
56
+ "@types/react": "^19.3.0",
57
+ "eslint": "^10.13.0",
58
+ "eslint-config-prettier": "^10.1.8",
59
+ "eslint-plugin-react-hooks": "^7.1.1",
60
+ "globals": "^17.13.0",
61
+ "ink-testing-library": "^4.0.0",
62
+ "prettier": "^3.9.10",
63
+ "tsx": "^4.23.15",
64
+ "typescript": "~6.0.3",
65
+ "typescript-eslint": "^8.72.1",
66
+ "vitest": "^5.0.5"
67
+ }
68
+ }