dbcat 0.0.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 +61 -0
- package/package.json +34 -0
- package/src/index.ts +340 -0
- package/src/table.ts +297 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# dbcat
|
|
2
|
+
|
|
3
|
+
A simple CLI to view database tables. Supports PostgreSQL, MySQL, and SQLite.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bunx dbcat ./data.db
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Connect to a database to browse all tables:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
# SQLite file
|
|
15
|
+
bunx dbcat ./database.sqlite
|
|
16
|
+
|
|
17
|
+
# PostgreSQL
|
|
18
|
+
bunx dbcat postgres://user:pass@localhost:5432/mydb
|
|
19
|
+
|
|
20
|
+
# MySQL
|
|
21
|
+
bunx dbcat mysql://user:pass@localhost:3306/mydb
|
|
22
|
+
|
|
23
|
+
# No argument - uses DATABASE_URL environment variable
|
|
24
|
+
bunx dbcat
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Run a query by piping SQL:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
echo "SELECT * FROM users WHERE active = true" | bunx dbcat ./data.db
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Options
|
|
34
|
+
|
|
35
|
+
| Flag | Description |
|
|
36
|
+
|------|-------------|
|
|
37
|
+
| `--full`, `-f` | Show all rows when browsing tables (default: 100) |
|
|
38
|
+
| `--json` | Output as JSON (indented if TTY) |
|
|
39
|
+
| `--json=color` | Output as normal object console.log |
|
|
40
|
+
|
|
41
|
+
Piped queries always return all rows.
|
|
42
|
+
|
|
43
|
+
## Example
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
$ bunx dbcat ./demo.sqlite
|
|
47
|
+
Connected to demo.sqlite
|
|
48
|
+
|
|
49
|
+
╭─ users ────────────┬───────────────────┬─────────────────────╮
|
|
50
|
+
│ id │ name │ email │ created_at │
|
|
51
|
+
├────┼───────────────┼───────────────────┼─────────────────────┤
|
|
52
|
+
│ 1 │ Alice Johnson │ alice@example.com │ 2025-12-08 05:25:21 │
|
|
53
|
+
│ 2 │ Bob Smith │ bob@example.com │ 2025-12-08 05:25:21 │
|
|
54
|
+
│ 3 │ Carol White │ carol@example.com │ 2025-12-08 05:25:21 │
|
|
55
|
+
│ ... 47 more rows │
|
|
56
|
+
╰──────────────────────────────────────────────────────────────╯
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
[Bun](https://bun.sh) v1.3+
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dbcat",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A simple CLI to view database tables. Supports PostgreSQL, MySQL, and SQLite.",
|
|
5
|
+
"author": "RiskyMH",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/RiskyMH/dbcat"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/RiskyMH/dbcat#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/RiskyMH/dbcat/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"database",
|
|
17
|
+
"cli",
|
|
18
|
+
"postgresql",
|
|
19
|
+
"mysql",
|
|
20
|
+
"sqlite",
|
|
21
|
+
"sql",
|
|
22
|
+
"bun"
|
|
23
|
+
],
|
|
24
|
+
"module": "src/index.ts",
|
|
25
|
+
"bin": "src/index.ts",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"files": [
|
|
28
|
+
"src/*"
|
|
29
|
+
],
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { SQL } from "bun";
|
|
4
|
+
import { printTable } from "./table.ts";
|
|
5
|
+
|
|
6
|
+
if (typeof Bun !== "object") throw new Error("Please install & use bun!");
|
|
7
|
+
|
|
8
|
+
export function createConnection(input?: string): InstanceType<typeof SQL> {
|
|
9
|
+
if (!input) {
|
|
10
|
+
return new SQL();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
!input.includes("://") &&
|
|
15
|
+
(input.endsWith(".db") ||
|
|
16
|
+
input.endsWith(".sqlite") ||
|
|
17
|
+
input.endsWith(".sqlite3"))
|
|
18
|
+
) {
|
|
19
|
+
return new SQL(`sqlite://${input}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return new SQL(input);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function getDatabaseName(
|
|
26
|
+
sql: InstanceType<typeof SQL>
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const adapter = sql.options.adapter;
|
|
29
|
+
|
|
30
|
+
switch (adapter) {
|
|
31
|
+
case "postgres": {
|
|
32
|
+
const result = await sql`SELECT current_database() as name`;
|
|
33
|
+
return result[0]?.name ?? null;
|
|
34
|
+
}
|
|
35
|
+
case "mysql":
|
|
36
|
+
case "mariadb": {
|
|
37
|
+
const result = await sql`SELECT DATABASE() as name`;
|
|
38
|
+
return result[0]?.name ?? null;
|
|
39
|
+
}
|
|
40
|
+
case "sqlite": {
|
|
41
|
+
const filename = (sql.options as { filename?: string }).filename;
|
|
42
|
+
if (!filename || filename === ":memory:") return null;
|
|
43
|
+
|
|
44
|
+
return filename.split("/").pop() ?? null;
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getAllTables(sql: InstanceType<typeof SQL>) {
|
|
52
|
+
const adapter = sql.options.adapter;
|
|
53
|
+
|
|
54
|
+
switch (adapter) {
|
|
55
|
+
case "postgres":
|
|
56
|
+
return sql`
|
|
57
|
+
SELECT table_name as name
|
|
58
|
+
FROM information_schema.tables
|
|
59
|
+
WHERE table_schema = 'public'
|
|
60
|
+
AND table_type = 'BASE TABLE'
|
|
61
|
+
ORDER BY table_name
|
|
62
|
+
` as Promise<{ name: string }[]>;
|
|
63
|
+
case "mysql":
|
|
64
|
+
case "mariadb":
|
|
65
|
+
return sql`
|
|
66
|
+
SELECT table_name as name
|
|
67
|
+
FROM information_schema.tables
|
|
68
|
+
WHERE table_schema = DATABASE()
|
|
69
|
+
AND table_type = 'BASE TABLE'
|
|
70
|
+
ORDER BY table_name
|
|
71
|
+
` as Promise<{ name: string }[]>;
|
|
72
|
+
case "sqlite":
|
|
73
|
+
return sql`
|
|
74
|
+
SELECT name
|
|
75
|
+
FROM sqlite_master
|
|
76
|
+
WHERE type = 'table'
|
|
77
|
+
AND name NOT LIKE 'sqlite_%'
|
|
78
|
+
ORDER BY name
|
|
79
|
+
` as Promise<{ name: string }[]>;
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`Unsupported adapter: ${adapter}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getTableCount(
|
|
86
|
+
sql: InstanceType<typeof SQL>,
|
|
87
|
+
tableName: string
|
|
88
|
+
): Promise<number> {
|
|
89
|
+
const result = await sql`SELECT COUNT(*) as count FROM ${sql(tableName)}`;
|
|
90
|
+
return Number(result[0]?.count ?? 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getTableData(
|
|
94
|
+
sql: InstanceType<typeof SQL>,
|
|
95
|
+
tableName: string,
|
|
96
|
+
limit?: number
|
|
97
|
+
) {
|
|
98
|
+
if (limit === undefined) {
|
|
99
|
+
return sql`SELECT * FROM ${sql(tableName)}` as Promise<
|
|
100
|
+
Record<string, unknown>[]
|
|
101
|
+
>;
|
|
102
|
+
}
|
|
103
|
+
return sql`SELECT * FROM ${sql(tableName)} LIMIT ${limit}` as Promise<
|
|
104
|
+
Record<string, unknown>[]
|
|
105
|
+
>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function runQuery(sql: InstanceType<typeof SQL>, query: string) {
|
|
109
|
+
return sql.unsafe(query) as Promise<Record<string, unknown>[]>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatTableOutput(
|
|
113
|
+
tableName: string,
|
|
114
|
+
rows: Record<string, unknown>[]
|
|
115
|
+
): string {
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
lines.push(`\n=== ${tableName} ===`);
|
|
118
|
+
|
|
119
|
+
if (rows.length === 0) {
|
|
120
|
+
lines.push("(empty table)");
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const maxRows = 100;
|
|
125
|
+
const displayRows = rows.slice(0, maxRows);
|
|
126
|
+
|
|
127
|
+
if (displayRows.length > 0) {
|
|
128
|
+
const keys = Object.keys(displayRows[0]!);
|
|
129
|
+
lines.push(keys.join(" | "));
|
|
130
|
+
lines.push("-".repeat(keys.join(" | ").length));
|
|
131
|
+
for (const row of displayRows) {
|
|
132
|
+
lines.push(keys.map((k) => String(row[k] ?? "NULL")).join(" | "));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (rows.length > maxRows) {
|
|
137
|
+
lines.push(`... and ${rows.length - maxRows} more rows`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatQueryResult(rows: Record<string, unknown>[]): string {
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
return "(no results)";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const lines: string[] = [];
|
|
149
|
+
const keys = Object.keys(rows[0]!);
|
|
150
|
+
lines.push(keys.join(" | "));
|
|
151
|
+
lines.push("-".repeat(keys.join(" | ").length));
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
lines.push(keys.map((k) => String(row[k] ?? "NULL")).join(" | "));
|
|
154
|
+
}
|
|
155
|
+
lines.push(`\n${rows.length} row(s)`);
|
|
156
|
+
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function displayTable(
|
|
161
|
+
tableName: string,
|
|
162
|
+
rows: Record<string, unknown>[],
|
|
163
|
+
totalRows?: number,
|
|
164
|
+
maxRows?: number
|
|
165
|
+
) {
|
|
166
|
+
console.log();
|
|
167
|
+
printTable(rows, { title: tableName, totalRows, maxRows });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function displayQueryResult(rows: Record<string, unknown>[]) {
|
|
171
|
+
printTable(rows, { title: "Result", maxRows: Infinity });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readStdin(): Promise<string | null> {
|
|
175
|
+
if (process.stdin.isTTY) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const text = await Bun.stdin.text();
|
|
181
|
+
return text.trim() || null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type JsonMode = false | "plain" | "color";
|
|
188
|
+
|
|
189
|
+
function parseArgs() {
|
|
190
|
+
const args = process.argv.slice(2);
|
|
191
|
+
let input: string | undefined;
|
|
192
|
+
let full = false;
|
|
193
|
+
let json: JsonMode = false;
|
|
194
|
+
|
|
195
|
+
for (const arg of args) {
|
|
196
|
+
if (arg === "--full" || arg === "-f") {
|
|
197
|
+
full = true;
|
|
198
|
+
} else if (arg === "--json") {
|
|
199
|
+
json = "plain";
|
|
200
|
+
} else if (arg === "--json=color") {
|
|
201
|
+
json = "color";
|
|
202
|
+
} else if (!arg.startsWith("-")) {
|
|
203
|
+
input = arg;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { input, full, json };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const DEFAULT_LIMIT = 100;
|
|
211
|
+
|
|
212
|
+
function showUsageAndExit(): never {
|
|
213
|
+
console.error("Usage: dbcli <database>");
|
|
214
|
+
console.error("");
|
|
215
|
+
console.error("Examples:");
|
|
216
|
+
console.error(" dbcli ./data.db");
|
|
217
|
+
console.error(" dbcli postgres://user:pass@localhost/mydb");
|
|
218
|
+
console.error(" dbcli mysql://user:pass@localhost/mydb");
|
|
219
|
+
console.error("");
|
|
220
|
+
console.error("Or set DATABASE_URL environment variable.");
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function outputJson(data: unknown, color: boolean) {
|
|
225
|
+
if (color) {
|
|
226
|
+
console.log(Bun.inspect(data, { colors: true, depth: Infinity }));
|
|
227
|
+
} else {
|
|
228
|
+
const indent = process.stdout.isTTY ? 2 : undefined;
|
|
229
|
+
console.log(JSON.stringify(data, null, indent));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const { input, full, json } = parseArgs();
|
|
235
|
+
|
|
236
|
+
if (!input && !process.env.DATABASE_URL) {
|
|
237
|
+
showUsageAndExit();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let sql: InstanceType<typeof SQL>;
|
|
241
|
+
try {
|
|
242
|
+
sql = createConnection(input);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error("Failed to connect:");
|
|
245
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const isTTY = process.stdout.isTTY;
|
|
250
|
+
const dim = Bun.enableANSIColors ? "\x1b[2m" : "";
|
|
251
|
+
const reset = Bun.enableANSIColors ? "\x1b[0m" : "";
|
|
252
|
+
const bold = Bun.enableANSIColors ? "\x1b[1m" : "";
|
|
253
|
+
const clearLine = "\x1b[2K\r";
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const stdinQuery = await readStdin();
|
|
257
|
+
|
|
258
|
+
if (stdinQuery) {
|
|
259
|
+
const results = await runQuery(sql, stdinQuery);
|
|
260
|
+
if (json) {
|
|
261
|
+
outputJson(results, json === "color");
|
|
262
|
+
} else {
|
|
263
|
+
displayQueryResult(results);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
if (isTTY && !json) {
|
|
267
|
+
process.stdout.write(
|
|
268
|
+
`${dim}Connecting to ${sql.options.adapter}...${reset}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const [dbName, tables] = await Promise.all([
|
|
273
|
+
getDatabaseName(sql),
|
|
274
|
+
getAllTables(sql),
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
if (json) {
|
|
278
|
+
const result: Record<string, unknown[]> = {};
|
|
279
|
+
for (const table of tables) {
|
|
280
|
+
const data = full
|
|
281
|
+
? await getTableData(sql, table.name)
|
|
282
|
+
: await getTableData(sql, table.name, DEFAULT_LIMIT);
|
|
283
|
+
result[table.name] = data;
|
|
284
|
+
}
|
|
285
|
+
outputJson(result, json === "color");
|
|
286
|
+
} else {
|
|
287
|
+
const dbDisplay = dbName
|
|
288
|
+
? `${bold}${dbName}${reset}`
|
|
289
|
+
: `${bold}${sql.options.adapter}${reset} ${dim}database${reset}`;
|
|
290
|
+
if (isTTY) {
|
|
291
|
+
process.stdout.write(
|
|
292
|
+
`${clearLine}${dim}Connected to${reset} ${dbDisplay}\n`
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
console.log(`Connected to ${dbName || sql.options.adapter}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (tables.length === 0) {
|
|
299
|
+
console.log(`${dim}No tables found.${reset}`);
|
|
300
|
+
} else {
|
|
301
|
+
for (const table of tables) {
|
|
302
|
+
if (full) {
|
|
303
|
+
const data = await getTableData(sql, table.name);
|
|
304
|
+
displayTable(table.name, data, undefined, Infinity);
|
|
305
|
+
} else {
|
|
306
|
+
const [count, data] = await Promise.all([
|
|
307
|
+
getTableCount(sql, table.name),
|
|
308
|
+
getTableData(sql, table.name, DEFAULT_LIMIT),
|
|
309
|
+
]);
|
|
310
|
+
displayTable(table.name, data, count, DEFAULT_LIMIT);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (isTTY && !json) {
|
|
318
|
+
process.stdout.write(clearLine);
|
|
319
|
+
}
|
|
320
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
321
|
+
|
|
322
|
+
if (
|
|
323
|
+
message.includes("Connection") ||
|
|
324
|
+
message.includes("connect") ||
|
|
325
|
+
message.includes("ECONNREFUSED")
|
|
326
|
+
) {
|
|
327
|
+
console.error(`Failed to connect to ${sql.options.adapter}:`);
|
|
328
|
+
} else {
|
|
329
|
+
console.error("Error:");
|
|
330
|
+
}
|
|
331
|
+
console.error(message);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
} finally {
|
|
334
|
+
await sql.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (import.meta.main) {
|
|
339
|
+
main();
|
|
340
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
const RESET = "\x1b[0m";
|
|
2
|
+
const DIM = "\x1b[2m";
|
|
3
|
+
const BOLD = "\x1b[1m";
|
|
4
|
+
|
|
5
|
+
const BOX = {
|
|
6
|
+
topLeft: "╭",
|
|
7
|
+
topRight: "╮",
|
|
8
|
+
bottomLeft: "╰",
|
|
9
|
+
bottomRight: "╯",
|
|
10
|
+
horizontal: "─",
|
|
11
|
+
vertical: "│",
|
|
12
|
+
headerLeft: "├",
|
|
13
|
+
headerRight: "┤",
|
|
14
|
+
headerCross: "┼",
|
|
15
|
+
topCross: "┬",
|
|
16
|
+
bottomCross: "┴",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getTerminalWidth(): number {
|
|
20
|
+
return process.stdout.columns || 120;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatValue(value: unknown): string {
|
|
24
|
+
if (value === null || value === undefined) {
|
|
25
|
+
const dim = Bun.enableANSIColors ? DIM : "";
|
|
26
|
+
const reset = Bun.enableANSIColors ? RESET : "";
|
|
27
|
+
return `${dim}NULL${reset}`;
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return Bun.inspect(value, { colors: Bun.enableANSIColors, depth: 2 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncate(str: string, maxWidth: number): string {
|
|
36
|
+
const width = Bun.stringWidth(str);
|
|
37
|
+
if (width <= maxWidth) {
|
|
38
|
+
return str;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let low = 0;
|
|
42
|
+
let high = str.length;
|
|
43
|
+
|
|
44
|
+
while (low < high) {
|
|
45
|
+
const mid = Math.floor((low + high + 1) / 2);
|
|
46
|
+
if (Bun.stringWidth(str.slice(0, mid)) + 1 <= maxWidth) {
|
|
47
|
+
low = mid;
|
|
48
|
+
} else {
|
|
49
|
+
high = mid - 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const truncated = str.slice(0, low) + "…";
|
|
54
|
+
return Bun.enableANSIColors ? truncated + RESET : truncated;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function padRight(str: string, width: number): string {
|
|
58
|
+
const strWidth = Bun.stringWidth(str);
|
|
59
|
+
if (strWidth >= width) {
|
|
60
|
+
return str;
|
|
61
|
+
}
|
|
62
|
+
return str + " ".repeat(width - strWidth);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function padLeft(str: string, width: number): string {
|
|
66
|
+
const strWidth = Bun.stringWidth(str);
|
|
67
|
+
if (strWidth >= width) {
|
|
68
|
+
return str;
|
|
69
|
+
}
|
|
70
|
+
return " ".repeat(width - strWidth) + str;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TableOptions {
|
|
74
|
+
maxRows?: number;
|
|
75
|
+
title?: string;
|
|
76
|
+
totalRows?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function printTable(
|
|
80
|
+
rows: Record<string, unknown>[],
|
|
81
|
+
options: TableOptions = {}
|
|
82
|
+
): void {
|
|
83
|
+
const { maxRows = 100, title, totalRows } = options;
|
|
84
|
+
const dim = Bun.enableANSIColors ? DIM : "";
|
|
85
|
+
const reset = Bun.enableANSIColors ? RESET : "";
|
|
86
|
+
const bold = Bun.enableANSIColors ? BOLD : "";
|
|
87
|
+
|
|
88
|
+
if (rows.length === 0) {
|
|
89
|
+
if (title) {
|
|
90
|
+
const contentWidth = Math.max(
|
|
91
|
+
Bun.stringWidth("(empty)"),
|
|
92
|
+
Bun.stringWidth(title)
|
|
93
|
+
);
|
|
94
|
+
const innerWidth = contentWidth + 2;
|
|
95
|
+
const titleDisplay = ` ${title} `;
|
|
96
|
+
const titleWidth = Bun.stringWidth(titleDisplay);
|
|
97
|
+
const remainingWidth = innerWidth - titleWidth;
|
|
98
|
+
console.log(
|
|
99
|
+
`${dim}${BOX.topLeft}${
|
|
100
|
+
BOX.horizontal
|
|
101
|
+
}${reset}${bold}${titleDisplay}${reset}${dim}${BOX.horizontal.repeat(
|
|
102
|
+
Math.max(0, remainingWidth)
|
|
103
|
+
)}${BOX.topRight}${reset}`
|
|
104
|
+
);
|
|
105
|
+
console.log(
|
|
106
|
+
`${dim}${BOX.vertical}${reset} ${padRight(
|
|
107
|
+
"(empty)",
|
|
108
|
+
contentWidth
|
|
109
|
+
)} ${dim}${BOX.vertical}${reset}`
|
|
110
|
+
);
|
|
111
|
+
console.log(
|
|
112
|
+
`${dim}${BOX.bottomLeft}${BOX.horizontal.repeat(innerWidth)}${
|
|
113
|
+
BOX.bottomRight
|
|
114
|
+
}${reset}`
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
console.log("(empty)");
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rowCount = rows.length;
|
|
123
|
+
const displayRows = maxRows === Infinity ? rows : rows.slice(0, maxRows);
|
|
124
|
+
const allColumns = Object.keys(displayRows[0]!);
|
|
125
|
+
const termWidth = getTerminalWidth();
|
|
126
|
+
|
|
127
|
+
const isNumericCol: boolean[] = allColumns.map((col) => {
|
|
128
|
+
return displayRows.every((row) => {
|
|
129
|
+
const val = row[col];
|
|
130
|
+
return (
|
|
131
|
+
val === null ||
|
|
132
|
+
val === undefined ||
|
|
133
|
+
typeof val === "number" ||
|
|
134
|
+
typeof val === "bigint"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const colWidths: number[] = allColumns.map((col) => Bun.stringWidth(col));
|
|
140
|
+
const formattedRows: string[][] = displayRows.map((row) =>
|
|
141
|
+
allColumns.map((col, i) => {
|
|
142
|
+
const formatted = formatValue(row[col]).replace(/\n/g, " ");
|
|
143
|
+
colWidths[i] = Math.max(colWidths[i]!, Bun.stringWidth(formatted));
|
|
144
|
+
return formatted;
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const minColWidth = 3;
|
|
149
|
+
const borderOverhead = 4 + (allColumns.length - 1) * 3;
|
|
150
|
+
let availableForColumns = termWidth - borderOverhead;
|
|
151
|
+
|
|
152
|
+
let columns = allColumns;
|
|
153
|
+
let hiddenCols = 0;
|
|
154
|
+
|
|
155
|
+
while (
|
|
156
|
+
columns.length * minColWidth > availableForColumns &&
|
|
157
|
+
columns.length > 1
|
|
158
|
+
) {
|
|
159
|
+
columns = columns.slice(0, -1);
|
|
160
|
+
hiddenCols++;
|
|
161
|
+
availableForColumns = termWidth - (4 + (columns.length - 1) * 3);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const visibleColWidths = colWidths.slice(0, columns.length);
|
|
165
|
+
const visibleFormattedRows = formattedRows.map((row) =>
|
|
166
|
+
row.slice(0, columns.length)
|
|
167
|
+
);
|
|
168
|
+
const visibleIsNumeric = isNumericCol.slice(0, columns.length);
|
|
169
|
+
|
|
170
|
+
let totalWidth = visibleColWidths.reduce((a, b) => a + b, 0);
|
|
171
|
+
|
|
172
|
+
if (totalWidth > availableForColumns) {
|
|
173
|
+
const headerWidths = columns.map((col) =>
|
|
174
|
+
Math.max(minColWidth, Bun.stringWidth(col))
|
|
175
|
+
);
|
|
176
|
+
const sqrtWidths = visibleColWidths.map((w) => Math.sqrt(w));
|
|
177
|
+
const sqrtTotal = sqrtWidths.reduce((a, b) => a + b, 0);
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < visibleColWidths.length; i++) {
|
|
180
|
+
const fair = Math.floor(
|
|
181
|
+
(sqrtWidths[i]! / sqrtTotal) * availableForColumns
|
|
182
|
+
);
|
|
183
|
+
visibleColWidths[i] = Math.max(headerWidths[i]!, fair);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
totalWidth = visibleColWidths.reduce((a, b) => a + b, 0);
|
|
187
|
+
while (
|
|
188
|
+
totalWidth > availableForColumns &&
|
|
189
|
+
visibleColWidths.some((w) => w > minColWidth)
|
|
190
|
+
) {
|
|
191
|
+
let maxIdx = 0;
|
|
192
|
+
for (let i = 1; i < visibleColWidths.length; i++) {
|
|
193
|
+
if (visibleColWidths[i]! > visibleColWidths[maxIdx]!) maxIdx = i;
|
|
194
|
+
}
|
|
195
|
+
visibleColWidths[maxIdx]!--;
|
|
196
|
+
totalWidth--;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const actualTotal = totalRows ?? rowCount;
|
|
201
|
+
const truncatedRows =
|
|
202
|
+
actualTotal > displayRows.length ? actualTotal - displayRows.length : 0;
|
|
203
|
+
let infoText = "";
|
|
204
|
+
if (truncatedRows > 0 || hiddenCols > 0) {
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
if (truncatedRows > 0)
|
|
207
|
+
parts.push(`${truncatedRows} more row${truncatedRows > 1 ? "s" : ""}`);
|
|
208
|
+
if (hiddenCols > 0)
|
|
209
|
+
parts.push(`${hiddenCols} more column${hiddenCols > 1 ? "s" : ""}`);
|
|
210
|
+
infoText = `... ${parts.join(", ")}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const contentWidth =
|
|
214
|
+
visibleColWidths.reduce((a, b) => a + b, 0) + (columns.length - 1) * 3;
|
|
215
|
+
|
|
216
|
+
const titleWidth = title ? Bun.stringWidth(title) + 4 : 0;
|
|
217
|
+
const maxInnerWidth = termWidth - 4;
|
|
218
|
+
const innerWidth = Math.min(
|
|
219
|
+
Math.max(contentWidth, titleWidth),
|
|
220
|
+
maxInnerWidth
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const totalInnerWidth = innerWidth + 2;
|
|
224
|
+
|
|
225
|
+
if (innerWidth > contentWidth && visibleColWidths.length > 0) {
|
|
226
|
+
visibleColWidths[visibleColWidths.length - 1]! += innerWidth - contentWidth;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const topBorder = visibleColWidths
|
|
230
|
+
.map((w) => BOX.horizontal.repeat(w))
|
|
231
|
+
.join(`${BOX.horizontal}${BOX.topCross}${BOX.horizontal}`);
|
|
232
|
+
const fullTopBorder = `${BOX.topLeft}${BOX.horizontal}${topBorder}${BOX.horizontal}${BOX.topRight}`;
|
|
233
|
+
if (title) {
|
|
234
|
+
const titleDisplay = ` ${title} `;
|
|
235
|
+
const titleDisplayWidth = Bun.stringWidth(titleDisplay);
|
|
236
|
+
|
|
237
|
+
const beforeTitle = fullTopBorder.slice(0, 2);
|
|
238
|
+
const afterTitle = fullTopBorder.slice(2 + titleDisplayWidth);
|
|
239
|
+
console.log(
|
|
240
|
+
`${dim}${beforeTitle}${reset}${bold}${titleDisplay}${reset}${dim}${afterTitle}${reset}`
|
|
241
|
+
);
|
|
242
|
+
} else {
|
|
243
|
+
console.log(`${dim}${fullTopBorder}${reset}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const header = columns
|
|
247
|
+
.map((col, i) => {
|
|
248
|
+
const truncated = truncate(col, visibleColWidths[i]!);
|
|
249
|
+
return visibleIsNumeric[i]
|
|
250
|
+
? padLeft(truncated, visibleColWidths[i]!)
|
|
251
|
+
: padRight(truncated, visibleColWidths[i]!);
|
|
252
|
+
})
|
|
253
|
+
.join(` ${dim}${BOX.vertical}${reset} `);
|
|
254
|
+
console.log(
|
|
255
|
+
`${dim}${BOX.vertical}${reset} ${header} ${dim}${BOX.vertical}${reset}`
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const headerSep = visibleColWidths
|
|
259
|
+
.map((w) => BOX.horizontal.repeat(w))
|
|
260
|
+
.join(`${BOX.horizontal}${BOX.headerCross}${BOX.horizontal}`);
|
|
261
|
+
console.log(
|
|
262
|
+
`${dim}${BOX.headerLeft}${BOX.horizontal}${headerSep}${BOX.horizontal}${BOX.headerRight}${reset}`
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
for (const row of visibleFormattedRows) {
|
|
266
|
+
const line = row
|
|
267
|
+
.map((val, i) => {
|
|
268
|
+
const truncated = truncate(val, visibleColWidths[i]!);
|
|
269
|
+
return visibleIsNumeric[i]
|
|
270
|
+
? padLeft(truncated, visibleColWidths[i]!)
|
|
271
|
+
: padRight(truncated, visibleColWidths[i]!);
|
|
272
|
+
})
|
|
273
|
+
.join(` ${dim}${BOX.vertical}${reset} `);
|
|
274
|
+
console.log(
|
|
275
|
+
`${dim}${BOX.vertical}${reset} ${line} ${dim}${BOX.vertical}${reset}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (infoText) {
|
|
280
|
+
const truncatedInfo = truncate(infoText, innerWidth);
|
|
281
|
+
const infoLine = padRight(truncatedInfo, innerWidth);
|
|
282
|
+
console.log(`${dim}${BOX.vertical} ${infoLine} ${BOX.vertical}${reset}`);
|
|
283
|
+
|
|
284
|
+
console.log(
|
|
285
|
+
`${dim}${BOX.bottomLeft}${BOX.horizontal.repeat(totalInnerWidth)}${
|
|
286
|
+
BOX.bottomRight
|
|
287
|
+
}${reset}`
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
const bottomBorder = visibleColWidths
|
|
291
|
+
.map((w) => BOX.horizontal.repeat(w))
|
|
292
|
+
.join(`${BOX.horizontal}${BOX.bottomCross}${BOX.horizontal}`);
|
|
293
|
+
console.log(
|
|
294
|
+
`${dim}${BOX.bottomLeft}${BOX.horizontal}${bottomBorder}${BOX.horizontal}${BOX.bottomRight}${reset}`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|