pgsqlio 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.
@@ -0,0 +1,1208 @@
1
+ // src/app.ts
2
+ import { createCliRenderer } from "@opentui/core";
3
+
4
+ // src/commands/dump.ts
5
+ import { createWriteStream, unlinkSync } from "fs";
6
+ import { spawn } from "child_process";
7
+ import { finished, pipeline } from "stream/promises";
8
+ import {
9
+ BoxRenderable as BoxRenderable3,
10
+ TextRenderable as TextRenderable3
11
+ } from "@opentui/core";
12
+ import { SpinnerRenderable as SpinnerRenderable2 } from "opentui-spinner";
13
+
14
+ // src/utils.ts
15
+ import { spawnSync } from "child_process";
16
+ function requireBin(name) {
17
+ const result = spawnSync(name, ["--version"], { encoding: "utf8" });
18
+ if (result.error || result.status !== 0) {
19
+ console.error(`\u274C Missing required binary: ${name} (install PostgreSQL client tools)`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+ function run(cmd, args, options) {
24
+ const result = spawnSync(cmd, args, {
25
+ encoding: "utf8",
26
+ input: options?.input,
27
+ stdio: options?.input !== void 0 ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"]
28
+ });
29
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
30
+ return { ok: result.status === 0, output };
31
+ }
32
+ function quoteIdent(name) {
33
+ return `"${name.replace(/"/g, '""')}"`;
34
+ }
35
+ function ensureDatabase(adminUrl, name) {
36
+ const lit = name.replace(/'/g, "''");
37
+ const { ok: checkOk, stdout } = runCapture("psql", [
38
+ adminUrl,
39
+ "-Atc",
40
+ `SELECT 1 FROM pg_database WHERE datname = '${lit}'`
41
+ ]);
42
+ if (checkOk && stdout.trim() === "1") {
43
+ return { ok: true, output: "" };
44
+ }
45
+ return run("psql", [adminUrl, "-v", "ON_ERROR_STOP=1", "-c", `CREATE DATABASE ${quoteIdent(name)}`]);
46
+ }
47
+ function wipePublicSchema(dbUrl) {
48
+ return run("psql", [
49
+ dbUrl,
50
+ "-v",
51
+ "ON_ERROR_STOP=1",
52
+ "-c",
53
+ "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public; GRANT ALL ON SCHEMA public TO CURRENT_USER;"
54
+ ]);
55
+ }
56
+ function extractConnectTargets(sql) {
57
+ const found = /* @__PURE__ */ new Set();
58
+ for (const m of sql.matchAll(/\\connect\s+"((?:\\.|[^"\\])*)"/gi)) {
59
+ found.add(m[1].replace(/""/g, '"'));
60
+ }
61
+ for (const m of sql.matchAll(/\\connect\s+([^\s"]+)/gi)) {
62
+ const name = m[1].replace(/;$/, "");
63
+ if (name.toLowerCase() !== "postgres") found.add(name);
64
+ }
65
+ found.delete("postgres");
66
+ return [...found];
67
+ }
68
+ function runCapture(cmd, args) {
69
+ const result = spawnSync(cmd, args, { encoding: "utf8" });
70
+ return {
71
+ ok: result.status === 0,
72
+ stdout: (result.stdout ?? "").trim(),
73
+ stderr: (result.stderr ?? "").trim()
74
+ };
75
+ }
76
+ function normalizeConnUrl(url) {
77
+ const [base, query] = url.split("?");
78
+ let next = base ?? url;
79
+ if (/^postgres(?:ql)?:\/\/[^/]+$/i.test(next)) {
80
+ next = `${next}/postgres`;
81
+ } else if (/^postgres(?:ql)?:\/\/[^/]+\/$/i.test(next)) {
82
+ next = `${next}postgres`;
83
+ }
84
+ return query !== void 0 ? `${next}?${query}` : next;
85
+ }
86
+ function replaceDbInUrl(url, newDb) {
87
+ const normalized = normalizeConnUrl(url);
88
+ const [base, query] = normalized.split("?");
89
+ const match = (base ?? normalized).match(/^(postgres(?:ql)?:\/\/[^/]+)/i);
90
+ const authority = match?.[1] ?? (base ?? normalized).replace(/\/[^/]*$/, "");
91
+ const replaced = `${authority}/${newDb}`;
92
+ return query !== void 0 ? `${replaced}?${query}` : replaced;
93
+ }
94
+ function timestamp() {
95
+ const d = /* @__PURE__ */ new Date();
96
+ const pad = (n) => String(n).padStart(2, "0");
97
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
98
+ }
99
+ function listDatabases(dbUrl) {
100
+ const url = normalizeConnUrl(dbUrl);
101
+ const { ok, stdout, stderr } = runCapture("psql", [
102
+ url,
103
+ "-Atc",
104
+ "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname;"
105
+ ]);
106
+ if (!ok) {
107
+ return { ok: false, databases: [], error: stderr || stdout || "Connection failed" };
108
+ }
109
+ return { ok: true, databases: stdout.split("\n").filter(Boolean) };
110
+ }
111
+ function dropDatabases(connUrl, names) {
112
+ const adminUrl = replaceDbInUrl(normalizeConnUrl(connUrl), "postgres");
113
+ const logs = [];
114
+ for (const name of names) {
115
+ if (name === "postgres" || name.startsWith("template")) {
116
+ return {
117
+ ok: false,
118
+ output: `Refusing to drop protected database: ${name}`
119
+ };
120
+ }
121
+ const lit = name.replace(/'/g, "''");
122
+ const terminate = run("psql", [
123
+ adminUrl,
124
+ "-c",
125
+ `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${lit}' AND pid <> pg_backend_pid();`
126
+ ]);
127
+ if (terminate.output) logs.push(terminate.output);
128
+ const dropped = run("psql", [
129
+ adminUrl,
130
+ "-v",
131
+ "ON_ERROR_STOP=1",
132
+ "-c",
133
+ `DROP DATABASE IF EXISTS ${quoteIdent(name)};`
134
+ ]);
135
+ if (dropped.output) logs.push(dropped.output);
136
+ if (!dropped.ok) {
137
+ return {
138
+ ok: false,
139
+ output: [`Failed dropping "${name}"`, ...logs].filter(Boolean).join("\n")
140
+ };
141
+ }
142
+ }
143
+ return { ok: true, output: logs.join("\n").trim() };
144
+ }
145
+
146
+ // src/tui/shared.ts
147
+ import {
148
+ BoxRenderable as BoxRenderable2,
149
+ InputRenderable,
150
+ InputRenderableEvents,
151
+ SelectRenderable,
152
+ SelectRenderableEvents,
153
+ TextRenderable as TextRenderable2
154
+ } from "@opentui/core";
155
+ import { SpinnerRenderable } from "opentui-spinner";
156
+
157
+ // src/tui/branding.ts
158
+ import {
159
+ ASCIIFontRenderable,
160
+ BoxRenderable,
161
+ TextRenderable
162
+ } from "@opentui/core";
163
+ var SHELL_ID = "app-shell";
164
+ var CONTENT_ID = "app-content";
165
+ var VERSION = "0.1.0";
166
+ function createHeader(renderer) {
167
+ const header = new BoxRenderable(renderer, {
168
+ id: "app-header",
169
+ width: "100%",
170
+ flexDirection: "column",
171
+ flexShrink: 0
172
+ });
173
+ const logo = new BoxRenderable(renderer, {
174
+ id: "branding-logo",
175
+ position: "relative",
176
+ marginBottom: 0
177
+ });
178
+ logo.add(
179
+ new ASCIIFontRenderable(renderer, {
180
+ id: "branding-shadow",
181
+ text: "pgsqlio",
182
+ font: "block",
183
+ color: "#1A2228",
184
+ position: "absolute",
185
+ left: 1,
186
+ top: 1,
187
+ zIndex: 0
188
+ })
189
+ );
190
+ logo.add(
191
+ new ASCIIFontRenderable(renderer, {
192
+ id: "branding-wordmark",
193
+ text: "pgsqlio",
194
+ font: "block",
195
+ color: ["#2DD4BF", "#67E8F9", "#E0F2FE"],
196
+ zIndex: 1
197
+ })
198
+ );
199
+ header.add(logo);
200
+ header.add(
201
+ new TextRenderable(renderer, {
202
+ id: "branding-tagline",
203
+ content: `PostgreSQL dump \xB7 restore \xB7 cleanup \xB7 v${VERSION}`,
204
+ fg: "#3F3F46",
205
+ marginTop: 1
206
+ })
207
+ );
208
+ return header;
209
+ }
210
+ function mountShell(renderer) {
211
+ for (const child of renderer.root.getChildren()) {
212
+ renderer.root.remove(child.id);
213
+ }
214
+ const shell = new BoxRenderable(renderer, {
215
+ id: SHELL_ID,
216
+ width: "100%",
217
+ height: "100%",
218
+ flexDirection: "column",
219
+ backgroundColor: "#0C0C0C",
220
+ padding: 1
221
+ });
222
+ shell.add(createHeader(renderer));
223
+ const content = new BoxRenderable(renderer, {
224
+ id: CONTENT_ID,
225
+ width: "100%",
226
+ flexGrow: 1,
227
+ flexShrink: 1,
228
+ flexDirection: "column",
229
+ marginTop: 1
230
+ });
231
+ shell.add(content);
232
+ renderer.root.add(shell);
233
+ return content;
234
+ }
235
+ function getContent(renderer) {
236
+ const shell = renderer.root.getChildren().find((c) => c.id === SHELL_ID);
237
+ if (!shell) {
238
+ return mountShell(renderer);
239
+ }
240
+ const content = shell.getChildren().find((c) => c.id === CONTENT_ID);
241
+ if (!content) {
242
+ return mountShell(renderer);
243
+ }
244
+ return content;
245
+ }
246
+
247
+ // src/tui/shared.ts
248
+ var BACK = "__back__";
249
+ function clearContent(renderer) {
250
+ const content = getContent(renderer);
251
+ for (const child of content.getChildren()) {
252
+ content.remove(child.id);
253
+ }
254
+ return content;
255
+ }
256
+ function looksLikePgUrl(value) {
257
+ return /^(postgres(ql)?:\/\/)/i.test(value.trim());
258
+ }
259
+ function promptMultiSelect(renderer, options) {
260
+ return new Promise((resolve) => {
261
+ const { items } = options;
262
+ if (items.length === 0) {
263
+ resolve([]);
264
+ return;
265
+ }
266
+ const content = clearContent(renderer);
267
+ const choices = [BACK, ...items];
268
+ const checked = /* @__PURE__ */ new Set();
269
+ let cursor = 1;
270
+ const panel = new BoxRenderable2(renderer, {
271
+ id: "pick-panel",
272
+ border: true,
273
+ borderColor: "#555555",
274
+ title: ` ${options.title} `,
275
+ flexDirection: "column",
276
+ padding: 1,
277
+ width: "100%",
278
+ flexGrow: 1
279
+ });
280
+ panel.add(
281
+ new TextRenderable2(renderer, {
282
+ id: "pick-hint",
283
+ content: "Space toggle \xB7 a all \xB7 Enter confirm \xB7 Esc back",
284
+ fg: "#888888",
285
+ marginBottom: 1
286
+ })
287
+ );
288
+ const list = new BoxRenderable2(renderer, {
289
+ id: "pick-list",
290
+ flexDirection: "column",
291
+ flexGrow: 1,
292
+ width: "100%"
293
+ });
294
+ const lines = choices.map((_, i) => {
295
+ const line = new TextRenderable2(renderer, {
296
+ id: `pick-line-${i}`,
297
+ content: "",
298
+ fg: "#CCCCCC"
299
+ });
300
+ list.add(line);
301
+ return line;
302
+ });
303
+ panel.add(list);
304
+ content.add(panel);
305
+ const redraw = () => {
306
+ for (let i = 0; i < choices.length; i++) {
307
+ const id = choices[i];
308
+ const isBack = id === BACK;
309
+ const marker = isBack ? " " : checked.has(id) ? "[x]" : "[ ]";
310
+ const pointer = i === cursor ? "\u25B6" : " ";
311
+ const label = isBack ? "\u2190 Back" : id;
312
+ lines[i].content = `${pointer} ${marker} ${label}`;
313
+ lines[i].fg = i === cursor ? "#FFFF66" : isBack ? "#88AADD" : "#CCCCCC";
314
+ }
315
+ };
316
+ redraw();
317
+ const onKey = (key) => {
318
+ if (key.name === "up" || key.name === "k") {
319
+ cursor = Math.max(0, cursor - 1);
320
+ redraw();
321
+ return;
322
+ }
323
+ if (key.name === "down" || key.name === "j") {
324
+ cursor = Math.min(choices.length - 1, cursor + 1);
325
+ redraw();
326
+ return;
327
+ }
328
+ if (key.name === "space") {
329
+ const id = choices[cursor];
330
+ if (id === BACK) {
331
+ cleanup();
332
+ resolve(BACK);
333
+ return;
334
+ }
335
+ if (checked.has(id)) checked.delete(id);
336
+ else checked.add(id);
337
+ redraw();
338
+ return;
339
+ }
340
+ if (key.name === "a") {
341
+ if (checked.size === items.length) checked.clear();
342
+ else items.forEach((item) => checked.add(item));
343
+ redraw();
344
+ return;
345
+ }
346
+ if (key.name === "escape") {
347
+ cleanup();
348
+ resolve(BACK);
349
+ return;
350
+ }
351
+ if (key.name === "return" || key.name === "enter") {
352
+ const id = choices[cursor];
353
+ if (id === BACK) {
354
+ cleanup();
355
+ resolve(BACK);
356
+ return;
357
+ }
358
+ if (checked.size === 0) return;
359
+ cleanup();
360
+ resolve([...checked]);
361
+ }
362
+ };
363
+ const cleanup = () => {
364
+ renderer.keyInput.off("keypress", onKey);
365
+ };
366
+ renderer.keyInput.on("keypress", onKey);
367
+ });
368
+ }
369
+ function promptSelect(renderer, options) {
370
+ return new Promise((resolve, reject) => {
371
+ const content = clearContent(renderer);
372
+ const panel = new BoxRenderable2(renderer, {
373
+ id: "select-panel",
374
+ border: true,
375
+ borderColor: "#555555",
376
+ title: ` ${options.title} `,
377
+ flexDirection: "column",
378
+ padding: 1,
379
+ width: "100%",
380
+ flexGrow: 1
381
+ });
382
+ panel.add(
383
+ new TextRenderable2(renderer, {
384
+ id: "select-message",
385
+ content: options.message,
386
+ fg: "#FFFFFF",
387
+ marginBottom: 1
388
+ })
389
+ );
390
+ const menu = new SelectRenderable(renderer, {
391
+ id: "select-menu",
392
+ width: "100%",
393
+ height: Math.max(4, options.choices.length * 2 + 1),
394
+ showDescription: true,
395
+ showScrollIndicator: false,
396
+ options: options.choices.map(
397
+ (c) => ({
398
+ name: c.name,
399
+ description: c.description,
400
+ value: c.value
401
+ })
402
+ )
403
+ });
404
+ panel.add(menu);
405
+ panel.add(
406
+ new TextRenderable2(renderer, {
407
+ id: "select-hint",
408
+ content: options.hint ?? (options.allowBack ? "\u2191/\u2193 move \xB7 Enter confirm \xB7 Esc back" : "\u2191/\u2193 move \xB7 Enter confirm \xB7 Ctrl+C quit"),
409
+ fg: "#888888",
410
+ marginTop: 1
411
+ })
412
+ );
413
+ content.add(panel);
414
+ menu.focus();
415
+ const onSelect = (_index, option) => {
416
+ cleanup();
417
+ resolve(option.value);
418
+ };
419
+ const onKey = (key) => {
420
+ if (key.name === "escape") {
421
+ cleanup();
422
+ if (options.allowBack) resolve(BACK);
423
+ else reject(new Error("cancelled"));
424
+ }
425
+ };
426
+ const cleanup = () => {
427
+ menu.off(SelectRenderableEvents.ITEM_SELECTED, onSelect);
428
+ renderer.keyInput.off("keypress", onKey);
429
+ };
430
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, onSelect);
431
+ renderer.keyInput.on("keypress", onKey);
432
+ });
433
+ }
434
+ function promptText(renderer, options) {
435
+ return new Promise((resolve, reject) => {
436
+ const content = clearContent(renderer);
437
+ const panel = new BoxRenderable2(renderer, {
438
+ id: "text-panel",
439
+ border: true,
440
+ borderColor: "#555555",
441
+ title: ` ${options.title} `,
442
+ flexDirection: "column",
443
+ padding: 1,
444
+ width: "100%",
445
+ flexGrow: 1,
446
+ gap: 1
447
+ });
448
+ panel.add(
449
+ new TextRenderable2(renderer, {
450
+ id: "text-label",
451
+ content: options.label,
452
+ fg: "#FFFFFF"
453
+ })
454
+ );
455
+ const error = new TextRenderable2(renderer, {
456
+ id: "text-error",
457
+ content: "",
458
+ fg: "#FF6666",
459
+ height: 1
460
+ });
461
+ const input = new InputRenderable(renderer, {
462
+ id: "text-input",
463
+ width: "100%",
464
+ height: 1,
465
+ placeholder: options.placeholder ?? "",
466
+ value: options.initial?.trim() ?? "",
467
+ maxLength: options.maxLength ?? 2e3,
468
+ backgroundColor: "#1A1A1A",
469
+ focusedBackgroundColor: "#2A2A2A",
470
+ textColor: "#FFFFFF",
471
+ cursorColor: "#88CCFF"
472
+ });
473
+ panel.add(input);
474
+ panel.add(error);
475
+ panel.add(
476
+ new TextRenderable2(renderer, {
477
+ id: "text-hint",
478
+ content: options.hint ?? "Enter confirm \xB7 Esc back",
479
+ fg: "#888888"
480
+ })
481
+ );
482
+ content.add(panel);
483
+ input.focus();
484
+ const submit = (value) => {
485
+ const trimmed = value.trim();
486
+ if (!trimmed) {
487
+ error.content = "Value is required";
488
+ return;
489
+ }
490
+ if (options.validate) {
491
+ const msg = options.validate(trimmed);
492
+ if (msg) {
493
+ error.content = msg;
494
+ return;
495
+ }
496
+ }
497
+ cleanup();
498
+ resolve(trimmed);
499
+ };
500
+ const onEnter = (value) => submit(value);
501
+ const onKey = (key) => {
502
+ if (key.name === "escape") {
503
+ cleanup();
504
+ reject(new Error("cancelled"));
505
+ }
506
+ };
507
+ const cleanup = () => {
508
+ input.off(InputRenderableEvents.ENTER, onEnter);
509
+ renderer.keyInput.off("keypress", onKey);
510
+ };
511
+ input.on(InputRenderableEvents.ENTER, onEnter);
512
+ renderer.keyInput.on("keypress", onKey);
513
+ });
514
+ }
515
+ function promptDbUrl(renderer, title, initial) {
516
+ return promptText(renderer, {
517
+ title,
518
+ label: "PostgreSQL connection URL",
519
+ placeholder: "postgresql://user:password@host:5432",
520
+ initial,
521
+ hint: "Enter confirm \xB7 Esc back \xB7 database name optional (you can pick next)",
522
+ validate: (value) => looksLikePgUrl(value) ? null : "URL must start with postgresql:// or postgres://"
523
+ }).then(normalizeConnUrl);
524
+ }
525
+ async function waitForEnter(renderer, message = "Press Enter to continue") {
526
+ const content = getContent(renderer);
527
+ const hint = new TextRenderable2(renderer, {
528
+ id: `wait-enter-${Date.now()}`,
529
+ content: message,
530
+ fg: "#888888",
531
+ marginTop: 1
532
+ });
533
+ const children = content.getChildren();
534
+ const last = children[children.length - 1];
535
+ if (last) {
536
+ try {
537
+ last.add(hint);
538
+ } catch {
539
+ content.add(hint);
540
+ }
541
+ } else {
542
+ content.add(hint);
543
+ }
544
+ await new Promise((resolve) => {
545
+ const onKey = (key) => {
546
+ if (key.name === "return" || key.name === "enter" || key.name === "q") {
547
+ renderer.keyInput.off("keypress", onKey);
548
+ resolve();
549
+ }
550
+ };
551
+ renderer.keyInput.on("keypress", onKey);
552
+ });
553
+ }
554
+ async function showStatus(renderer, options) {
555
+ const content = clearContent(renderer);
556
+ const panel = new BoxRenderable2(renderer, {
557
+ id: "status-panel",
558
+ border: true,
559
+ borderColor: "#555555",
560
+ title: ` ${options.title} `,
561
+ flexDirection: "column",
562
+ padding: 1,
563
+ width: "100%",
564
+ flexGrow: 1
565
+ });
566
+ panel.add(
567
+ new TextRenderable2(renderer, {
568
+ content: options.message,
569
+ fg: options.ok ? "#66DD88" : "#FF6666",
570
+ marginBottom: options.detail ? 1 : 0
571
+ })
572
+ );
573
+ if (options.detail) {
574
+ const lines = options.detail.split("\n").filter(Boolean).slice(-8);
575
+ for (const [i, line] of lines.entries()) {
576
+ panel.add(
577
+ new TextRenderable2(renderer, {
578
+ id: `status-detail-${i}`,
579
+ content: line.length > 100 ? `${line.slice(0, 97)}\u2026` : line,
580
+ fg: "#A1A1AA"
581
+ })
582
+ );
583
+ }
584
+ }
585
+ content.add(panel);
586
+ await waitForEnter(renderer);
587
+ }
588
+ async function withSpinner(renderer, title, label, work) {
589
+ const content = clearContent(renderer);
590
+ const panel = new BoxRenderable2(renderer, {
591
+ id: "spinner-panel",
592
+ border: true,
593
+ borderColor: "#555555",
594
+ title: ` ${title} `,
595
+ flexDirection: "row",
596
+ alignItems: "center",
597
+ padding: 1,
598
+ width: "100%",
599
+ flexGrow: 1
600
+ });
601
+ const spin = new SpinnerRenderable(renderer, {
602
+ id: "work-spin",
603
+ name: "dots",
604
+ color: "#88CCFF"
605
+ });
606
+ panel.add(spin);
607
+ panel.add(
608
+ new TextRenderable2(renderer, {
609
+ content: ` ${label}`,
610
+ fg: "#CCCCCC",
611
+ marginLeft: 1
612
+ })
613
+ );
614
+ content.add(panel);
615
+ try {
616
+ return await work();
617
+ } finally {
618
+ spin.stop();
619
+ }
620
+ }
621
+
622
+ // src/commands/dump.ts
623
+ var PG_DUMP_ARGS = ["--clean", "--if-exists"];
624
+ function quoteIdent2(name) {
625
+ return `"${name.replace(/"/g, '""')}"`;
626
+ }
627
+ function sqlString(name) {
628
+ return `'${name.replace(/'/g, "''")}'`;
629
+ }
630
+ async function waitExit(child) {
631
+ if (child.exitCode !== null) return child.exitCode;
632
+ return new Promise((resolve) => child.once("close", resolve));
633
+ }
634
+ async function dumpOne(url, outFile) {
635
+ const child = spawn("pg_dump", [...PG_DUMP_ARGS, url], {
636
+ stdio: ["ignore", "pipe", "inherit"]
637
+ });
638
+ const out = createWriteStream(outFile);
639
+ try {
640
+ await pipeline(child.stdout, out);
641
+ } catch {
642
+ return false;
643
+ }
644
+ return await waitExit(child) === 0;
645
+ }
646
+ async function dumpAppend(url, out, dbName) {
647
+ const header = `
648
+ --
649
+ -- Database: ${dbName}
650
+ --
651
+ \\connect postgres
652
+ SELECT format('CREATE DATABASE %I', ${sqlString(dbName)})
653
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${sqlString(dbName)})\\gexec
654
+ \\connect ${quoteIdent2(dbName)}
655
+
656
+ `;
657
+ await new Promise((resolve, reject) => {
658
+ out.write(header, (err) => err ? reject(err) : resolve());
659
+ });
660
+ const child = spawn("pg_dump", [...PG_DUMP_ARGS, url], {
661
+ stdio: ["ignore", "pipe", "inherit"]
662
+ });
663
+ child.stdout.pipe(out, { end: false });
664
+ try {
665
+ await finished(child.stdout);
666
+ } catch {
667
+ return false;
668
+ }
669
+ return await waitExit(child) === 0;
670
+ }
671
+ async function runProgress(renderer, dbUrl, selected, outputMode) {
672
+ const content = clearContent(renderer);
673
+ const ts = timestamp();
674
+ let failed = 0;
675
+ const combinedFile = outputMode === "single" ? `backup_combined_${ts}.sql` : null;
676
+ const panel = new BoxRenderable3(renderer, {
677
+ id: "progress-panel",
678
+ border: true,
679
+ borderColor: "#555555",
680
+ title: outputMode === "single" ? ` dumping ${selected.length} database(s) \u2192 one file ` : ` dumping ${selected.length} database(s) `,
681
+ flexDirection: "column",
682
+ padding: 1,
683
+ width: "100%",
684
+ flexGrow: 1
685
+ });
686
+ const status = new TextRenderable3(renderer, {
687
+ id: "progress-status",
688
+ content: combinedFile ? `Writing ${combinedFile}\u2026` : "Starting\u2026",
689
+ fg: "#AAAAAA",
690
+ marginBottom: 1
691
+ });
692
+ panel.add(status);
693
+ const rows = /* @__PURE__ */ new Map();
694
+ for (const db of selected) {
695
+ const box = new BoxRenderable3(renderer, {
696
+ id: `row-${db}`,
697
+ flexDirection: "row",
698
+ alignItems: "center",
699
+ height: 1,
700
+ width: "100%"
701
+ });
702
+ const spinner = new SpinnerRenderable2(renderer, {
703
+ id: `spin-${db}`,
704
+ name: "dots",
705
+ color: "#88CCFF",
706
+ autoplay: false
707
+ });
708
+ const label = new TextRenderable3(renderer, {
709
+ id: `label-${db}`,
710
+ content: ` ${db} pending`,
711
+ fg: "#888888",
712
+ marginLeft: 1
713
+ });
714
+ box.add(spinner);
715
+ box.add(label);
716
+ panel.add(box);
717
+ rows.set(db, { spinner, label });
718
+ }
719
+ content.add(panel);
720
+ const combined = combinedFile ? createWriteStream(combinedFile) : null;
721
+ if (combined) {
722
+ combined.write(
723
+ `-- pgsqlio combined dump
724
+ -- databases: ${selected.join(", ")}
725
+ -- created: ${ts}
726
+ `
727
+ );
728
+ }
729
+ for (let i = 0; i < selected.length; i++) {
730
+ const db = selected[i];
731
+ const url = replaceDbInUrl(dbUrl, db);
732
+ const row = rows.get(db);
733
+ const outLabel = combinedFile ?? `backup_${db}_${ts}.sql`;
734
+ status.content = `Dumping ${i + 1}/${selected.length}: ${db}`;
735
+ row.label.content = ` ${db} \u2192 ${outLabel}`;
736
+ row.label.fg = "#FFFFFF";
737
+ row.spinner.start();
738
+ const ok = combined ? await dumpAppend(url, combined, db) : await dumpOne(url, outLabel);
739
+ row.spinner.stop();
740
+ row.spinner.visible = false;
741
+ if (ok) {
742
+ row.label.content = `\u2713 ${db} \u2192 ${outLabel}`;
743
+ row.label.fg = "#66DD88";
744
+ } else {
745
+ row.label.content = `\u2717 ${db} failed`;
746
+ row.label.fg = "#FF6666";
747
+ failed += 1;
748
+ if (!combined) {
749
+ try {
750
+ unlinkSync(outLabel);
751
+ } catch {
752
+ }
753
+ }
754
+ }
755
+ }
756
+ if (combined) {
757
+ await new Promise((resolve, reject) => {
758
+ combined.end(() => resolve());
759
+ combined.on("error", reject);
760
+ });
761
+ if (failed > 0) {
762
+ try {
763
+ unlinkSync(combinedFile);
764
+ } catch {
765
+ }
766
+ }
767
+ }
768
+ status.content = failed === 0 ? combinedFile ? `Done \u2014 ${selected.length} database(s) \u2192 ${combinedFile}` : `Done \u2014 ${selected.length} database(s) dumped` : `Finished with ${failed} failure(s)`;
769
+ status.fg = failed === 0 ? "#66DD88" : "#FFAA66";
770
+ panel.add(
771
+ new TextRenderable3(renderer, {
772
+ id: "progress-done",
773
+ content: "Press Enter to continue",
774
+ fg: "#888888",
775
+ marginTop: 1
776
+ })
777
+ );
778
+ await new Promise((resolve) => {
779
+ const onKey = (key) => {
780
+ if (key.name === "return" || key.name === "enter" || key.name === "q") {
781
+ renderer.keyInput.off("keypress", onKey);
782
+ resolve();
783
+ }
784
+ };
785
+ renderer.keyInput.on("keypress", onKey);
786
+ });
787
+ return failed;
788
+ }
789
+ async function runDumpFlow(renderer) {
790
+ requireBin("psql");
791
+ requireBin("pg_dump");
792
+ let dbUrl;
793
+ try {
794
+ dbUrl = await promptDbUrl(renderer, "pgsqlio \xB7 dump");
795
+ } catch {
796
+ return "back";
797
+ }
798
+ const content = clearContent(renderer);
799
+ const loading = new BoxRenderable3(renderer, {
800
+ id: "loading",
801
+ flexDirection: "row",
802
+ alignItems: "center",
803
+ padding: 1,
804
+ flexGrow: 1
805
+ });
806
+ const spin = new SpinnerRenderable2(renderer, {
807
+ id: "loading-spin",
808
+ name: "dots",
809
+ color: "#88CCFF"
810
+ });
811
+ loading.add(spin);
812
+ loading.add(
813
+ new TextRenderable3(renderer, {
814
+ id: "loading-text",
815
+ content: " Fetching database list\u2026",
816
+ fg: "#CCCCCC",
817
+ marginLeft: 1
818
+ })
819
+ );
820
+ content.add(loading);
821
+ const listed = listDatabases(dbUrl);
822
+ spin.stop();
823
+ const databases = listed.databases;
824
+ if (!listed.ok || databases.length === 0) {
825
+ const failContent = clearContent(renderer);
826
+ failContent.add(
827
+ new TextRenderable3(renderer, {
828
+ content: listed.ok ? "\u274C No databases found" : `\u274C Connection failed: ${listed.error ?? "unknown error"}`,
829
+ fg: "#FF6666",
830
+ padding: 1
831
+ })
832
+ );
833
+ await new Promise((r) => setTimeout(r, 2e3));
834
+ return "fail";
835
+ }
836
+ let selected;
837
+ let outputMode = "separate";
838
+ while (!selected) {
839
+ const mode = await promptSelect(renderer, {
840
+ title: "pgsqlio \xB7 dump",
841
+ message: "What do you want to dump?",
842
+ allowBack: true,
843
+ choices: [
844
+ {
845
+ name: `All databases (${databases.length})`,
846
+ description: "Dump every non-template database",
847
+ value: "all"
848
+ },
849
+ {
850
+ name: "Select databases",
851
+ description: "Pick databases with checkboxes",
852
+ value: "select"
853
+ }
854
+ ]
855
+ });
856
+ if (mode === BACK) return "back";
857
+ if (mode === "all") {
858
+ selected = [...databases];
859
+ } else {
860
+ const picks = await promptMultiSelect(renderer, {
861
+ title: "select databases",
862
+ items: databases
863
+ });
864
+ if (picks === BACK) continue;
865
+ if (picks.length === 0) continue;
866
+ selected = picks;
867
+ }
868
+ if (selected.length > 1) {
869
+ const fileMode = await promptSelect(renderer, {
870
+ title: "pgsqlio \xB7 dump",
871
+ message: `How should ${selected.length} databases be written?`,
872
+ allowBack: true,
873
+ choices: [
874
+ {
875
+ name: "Separate files",
876
+ description: "One backup_<db>_\u2026.sql file per database",
877
+ value: "separate"
878
+ },
879
+ {
880
+ name: "Single file",
881
+ description: "One backup_combined_\u2026.sql with \\connect between DBs",
882
+ value: "single"
883
+ }
884
+ ]
885
+ });
886
+ if (fileMode === BACK) {
887
+ selected = void 0;
888
+ continue;
889
+ }
890
+ outputMode = fileMode;
891
+ } else {
892
+ outputMode = "separate";
893
+ }
894
+ }
895
+ const failed = await runProgress(renderer, dbUrl, selected, outputMode);
896
+ return failed === 0 ? "ok" : "fail";
897
+ }
898
+
899
+ // src/commands/restore.ts
900
+ import { existsSync, readFileSync } from "fs";
901
+ async function runRestoreFlow(renderer) {
902
+ requireBin("psql");
903
+ let dbUrl;
904
+ try {
905
+ dbUrl = await promptDbUrl(renderer, "pgsqlio \xB7 restore");
906
+ } catch {
907
+ return "back";
908
+ }
909
+ let file;
910
+ try {
911
+ file = await promptText(renderer, {
912
+ title: "pgsqlio \xB7 restore",
913
+ label: "SQL backup file path",
914
+ placeholder: "backup_mydb_20250924_121530.sql",
915
+ validate: (value) => existsSync(value) ? null : "File not found \u2014 check the path"
916
+ });
917
+ } catch {
918
+ return "back";
919
+ }
920
+ let sql = "";
921
+ try {
922
+ sql = readFileSync(file, "utf8");
923
+ } catch {
924
+ await showStatus(renderer, {
925
+ title: "pgsqlio \xB7 restore",
926
+ message: "\u274C Could not read backup file",
927
+ ok: false
928
+ });
929
+ return "fail";
930
+ }
931
+ const targets = extractConnectTargets(sql);
932
+ const isCombined = targets.length > 0;
933
+ const adminUrl = replaceDbInUrl(dbUrl, "postgres");
934
+ const confirm = await promptSelect(renderer, {
935
+ title: "pgsqlio \xB7 restore",
936
+ message: isCombined ? `Restore ${targets.length} database(s) from ${file}?` : `Restore into this database from ${file}?`,
937
+ allowBack: true,
938
+ choices: [
939
+ {
940
+ name: "Wipe schemas, then restore",
941
+ description: "DROP SCHEMA public CASCADE on target DB(s) first (fixes \u201Calready exists\u201D)",
942
+ value: "wipe"
943
+ },
944
+ {
945
+ name: "Restore as-is",
946
+ description: "Do not wipe \u2014 fails if objects already exist",
947
+ value: "asis"
948
+ },
949
+ { name: "Cancel", description: "Return to main menu", value: "no" }
950
+ ]
951
+ });
952
+ if (confirm === BACK || confirm === "no") return "back";
953
+ const wipeFirst = confirm === "wipe";
954
+ const result = await withSpinner(
955
+ renderer,
956
+ "pgsqlio \xB7 restore",
957
+ wipeFirst ? "Wiping + restoring\u2026" : "Restoring\u2026",
958
+ () => {
959
+ const logs = [];
960
+ if (isCombined) {
961
+ for (const db of targets) {
962
+ const created = ensureDatabase(adminUrl, db);
963
+ if (!created.ok) {
964
+ return {
965
+ ok: false,
966
+ output: [`Failed creating database "${db}"`, created.output].filter(Boolean).join("\n")
967
+ };
968
+ }
969
+ if (created.output) logs.push(created.output);
970
+ if (wipeFirst) {
971
+ const wiped = wipePublicSchema(replaceDbInUrl(adminUrl, db));
972
+ if (!wiped.ok) {
973
+ return {
974
+ ok: false,
975
+ output: [`Failed wiping database "${db}"`, wiped.output].filter(Boolean).join("\n")
976
+ };
977
+ }
978
+ if (wiped.output) logs.push(wiped.output);
979
+ }
980
+ }
981
+ } else if (wipeFirst) {
982
+ const wiped = wipePublicSchema(dbUrl);
983
+ if (!wiped.ok) {
984
+ return {
985
+ ok: false,
986
+ output: ["Failed wiping target database", wiped.output].filter(Boolean).join("\n")
987
+ };
988
+ }
989
+ if (wiped.output) logs.push(wiped.output);
990
+ }
991
+ const restoreUrl = isCombined ? adminUrl : dbUrl;
992
+ const restored = run("psql", [
993
+ restoreUrl,
994
+ "-v",
995
+ "ON_ERROR_STOP=1",
996
+ "-f",
997
+ file
998
+ ]);
999
+ if (restored.output) logs.push(restored.output);
1000
+ return {
1001
+ ok: restored.ok,
1002
+ output: logs.join("\n").trim()
1003
+ };
1004
+ }
1005
+ );
1006
+ await showStatus(renderer, {
1007
+ title: "pgsqlio \xB7 restore",
1008
+ message: result.ok ? "\u2705 Restore successful" : "\u274C Restore failed",
1009
+ ok: result.ok,
1010
+ detail: result.ok ? void 0 : result.output || void 0
1011
+ });
1012
+ return result.ok ? "ok" : "fail";
1013
+ }
1014
+
1015
+ // src/commands/cleanup.ts
1016
+ async function runCleanupFlow(renderer) {
1017
+ requireBin("psql");
1018
+ let dbUrl;
1019
+ try {
1020
+ dbUrl = await promptDbUrl(renderer, "pgsqlio \xB7 cleanup");
1021
+ } catch {
1022
+ return "back";
1023
+ }
1024
+ const confirm = await promptSelect(renderer, {
1025
+ title: "pgsqlio \xB7 cleanup",
1026
+ message: "Drop and recreate the public schema? This is destructive.",
1027
+ allowBack: true,
1028
+ choices: [
1029
+ {
1030
+ name: "Yes, clean database",
1031
+ description: "DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
1032
+ value: "yes"
1033
+ },
1034
+ { name: "Cancel", description: "Return to main menu", value: "no" }
1035
+ ]
1036
+ });
1037
+ if (confirm === BACK || confirm === "no") return "back";
1038
+ const result = await withSpinner(
1039
+ renderer,
1040
+ "pgsqlio \xB7 cleanup",
1041
+ "Cleaning\u2026",
1042
+ () => run("psql", [
1043
+ dbUrl,
1044
+ "-v",
1045
+ "ON_ERROR_STOP=1",
1046
+ "-c",
1047
+ "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
1048
+ ])
1049
+ );
1050
+ await showStatus(renderer, {
1051
+ title: "pgsqlio \xB7 cleanup",
1052
+ message: result.ok ? "\u2705 Cleanup successful (database is empty)" : "\u274C Cleanup failed",
1053
+ ok: result.ok,
1054
+ detail: result.ok ? void 0 : result.output || void 0
1055
+ });
1056
+ return result.ok ? "ok" : "fail";
1057
+ }
1058
+
1059
+ // src/commands/drop-databases.ts
1060
+ import {
1061
+ BoxRenderable as BoxRenderable4,
1062
+ TextRenderable as TextRenderable4
1063
+ } from "@opentui/core";
1064
+ import { SpinnerRenderable as SpinnerRenderable3 } from "opentui-spinner";
1065
+ async function runDropDatabasesFlow(renderer) {
1066
+ requireBin("psql");
1067
+ let connUrl;
1068
+ try {
1069
+ connUrl = await promptDbUrl(renderer, "pgsqlio \xB7 drop databases");
1070
+ } catch {
1071
+ return "back";
1072
+ }
1073
+ connUrl = normalizeConnUrl(connUrl);
1074
+ const loadingWrap = clearContent(renderer);
1075
+ const spin = new SpinnerRenderable3(renderer, {
1076
+ id: "db-spin",
1077
+ name: "dots",
1078
+ color: "#88CCFF"
1079
+ });
1080
+ const loading = new BoxRenderable4(renderer, {
1081
+ id: "db-loading",
1082
+ flexDirection: "row",
1083
+ alignItems: "center",
1084
+ padding: 1,
1085
+ flexGrow: 1
1086
+ });
1087
+ loading.add(spin);
1088
+ loading.add(
1089
+ new TextRenderable4(renderer, {
1090
+ content: " Fetching databases\u2026",
1091
+ fg: "#CCCCCC",
1092
+ marginLeft: 1
1093
+ })
1094
+ );
1095
+ loadingWrap.add(loading);
1096
+ const listed = listDatabases(connUrl);
1097
+ spin.stop();
1098
+ if (!listed.ok) {
1099
+ await showStatus(renderer, {
1100
+ title: "pgsqlio \xB7 drop databases",
1101
+ message: "\u274C Could not list databases",
1102
+ ok: false,
1103
+ detail: listed.error
1104
+ });
1105
+ return "fail";
1106
+ }
1107
+ const droppable = listed.databases.filter(
1108
+ (name) => name !== "postgres" && !name.startsWith("template")
1109
+ );
1110
+ if (droppable.length === 0) {
1111
+ await showStatus(renderer, {
1112
+ title: "pgsqlio \xB7 drop databases",
1113
+ message: "No droppable databases found",
1114
+ ok: false,
1115
+ detail: "postgres / template* are protected"
1116
+ });
1117
+ return "fail";
1118
+ }
1119
+ const selected = await promptMultiSelect(renderer, {
1120
+ title: "select databases to drop",
1121
+ items: droppable
1122
+ });
1123
+ if (selected === BACK) return "back";
1124
+ if (selected.length === 0) return "back";
1125
+ const confirm = await promptSelect(renderer, {
1126
+ title: "pgsqlio \xB7 drop databases",
1127
+ message: `Permanently drop ${selected.length} database(s)? This cannot be undone.`,
1128
+ allowBack: true,
1129
+ choices: [
1130
+ {
1131
+ name: "Yes, drop selected databases",
1132
+ description: selected.slice(0, 5).join(", ") + (selected.length > 5 ? "\u2026" : ""),
1133
+ value: "yes"
1134
+ },
1135
+ { name: "Cancel", description: "Return to main menu", value: "no" }
1136
+ ]
1137
+ });
1138
+ if (confirm === BACK || confirm === "no") return "back";
1139
+ const result = await withSpinner(
1140
+ renderer,
1141
+ "pgsqlio \xB7 drop databases",
1142
+ `Dropping ${selected.length} database(s)\u2026`,
1143
+ () => dropDatabases(connUrl, selected)
1144
+ );
1145
+ await showStatus(renderer, {
1146
+ title: "pgsqlio \xB7 drop databases",
1147
+ message: result.ok ? `\u2705 Dropped ${selected.length} database(s)` : "\u274C Drop databases failed",
1148
+ ok: result.ok,
1149
+ detail: result.ok ? selected.join(", ") : result.output || void 0
1150
+ });
1151
+ return result.ok ? "ok" : "fail";
1152
+ }
1153
+
1154
+ // src/app.ts
1155
+ async function runApp() {
1156
+ const renderer = await createCliRenderer({
1157
+ exitOnCtrlC: true,
1158
+ backgroundColor: "#0C0C0C"
1159
+ });
1160
+ renderer.start();
1161
+ mountShell(renderer);
1162
+ try {
1163
+ while (true) {
1164
+ const action = await promptSelect(renderer, {
1165
+ title: "pgsqlio",
1166
+ message: "What do you want to do?",
1167
+ choices: [
1168
+ {
1169
+ name: "Dump",
1170
+ description: "Backup one or more databases",
1171
+ value: "dump"
1172
+ },
1173
+ {
1174
+ name: "Restore",
1175
+ description: "Import a .sql backup into a database",
1176
+ value: "restore"
1177
+ },
1178
+ {
1179
+ name: "Cleanup",
1180
+ description: "Drop and recreate the public schema",
1181
+ value: "cleanup"
1182
+ },
1183
+ {
1184
+ name: "Drop databases",
1185
+ description: "Select and permanently drop databases",
1186
+ value: "drop-databases"
1187
+ },
1188
+ {
1189
+ name: "Quit",
1190
+ description: "Exit pgsqlio",
1191
+ value: "quit"
1192
+ }
1193
+ ]
1194
+ });
1195
+ if (action === BACK || action === "quit") break;
1196
+ if (action === "dump") await runDumpFlow(renderer);
1197
+ else if (action === "restore") await runRestoreFlow(renderer);
1198
+ else if (action === "cleanup") await runCleanupFlow(renderer);
1199
+ else if (action === "drop-databases") await runDropDatabasesFlow(renderer);
1200
+ }
1201
+ } finally {
1202
+ renderer.destroy();
1203
+ }
1204
+ }
1205
+ export {
1206
+ runApp
1207
+ };
1208
+ //# sourceMappingURL=app-74TNCHFZ.js.map