@hienlh/ppm 0.6.2 → 0.6.4

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 (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
  4. package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
  5. package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
  6. package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
  7. package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
  8. package/dist/web/assets/index-DhuAmTQ1.js +21 -0
  9. package/dist/web/assets/index-aIGuIMQ8.css +2 -0
  10. package/dist/web/assets/input-CCCPR1s4.js +41 -0
  11. package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
  12. package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
  15. package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
  16. package/dist/web/assets/react-l9v2XLcs.js +1 -0
  17. package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
  18. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  19. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
  20. package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
  21. package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-3tDV4RCn.js} +1 -1
  22. package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  23. package/dist/web/index.html +9 -8
  24. package/dist/web/sw.js +1 -1
  25. package/docs/codebase-summary.md +41 -14
  26. package/docs/project-roadmap.md +31 -6
  27. package/docs/system-architecture.md +222 -7
  28. package/package.json +1 -1
  29. package/src/cli/commands/db-cmd.ts +355 -0
  30. package/src/server/index.ts +6 -0
  31. package/src/server/routes/database.ts +259 -0
  32. package/src/server/routes/settings.ts +33 -0
  33. package/src/services/database/adapter-registry.ts +13 -0
  34. package/src/services/database/init-adapters.ts +9 -0
  35. package/src/services/database/postgres-adapter.ts +42 -0
  36. package/src/services/database/readonly-check.ts +17 -0
  37. package/src/services/database/sqlite-adapter.ts +55 -0
  38. package/src/services/db.service.ts +173 -2
  39. package/src/services/table-cache.service.ts +75 -0
  40. package/src/types/database.ts +50 -0
  41. package/src/web/app.tsx +11 -1
  42. package/src/web/components/database/connection-color-picker.tsx +67 -0
  43. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  44. package/src/web/components/database/connection-list.tsx +208 -0
  45. package/src/web/components/database/database-sidebar.tsx +100 -0
  46. package/src/web/components/database/use-connections.ts +99 -0
  47. package/src/web/components/layout/command-palette.tsx +57 -6
  48. package/src/web/components/layout/draggable-tab.tsx +13 -2
  49. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  50. package/src/web/components/layout/sidebar.tsx +6 -1
  51. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  52. package/src/web/components/postgres/use-postgres.ts +57 -21
  53. package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
  54. package/src/web/components/settings/settings-tab.tsx +5 -0
  55. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  56. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  57. package/src/web/hooks/use-global-keybindings.ts +74 -14
  58. package/src/web/lib/api-client.ts +7 -1
  59. package/src/web/lib/color-utils.ts +23 -0
  60. package/src/web/stores/keybindings-store.ts +192 -0
  61. package/src/web/stores/settings-store.ts +2 -2
  62. package/dist/web/assets/api-client-DPWUomlf.js +0 -1
  63. package/dist/web/assets/code-editor-soN1frMc.js +0 -1
  64. package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
  65. package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
  66. package/dist/web/assets/index-CmrE0Xoy.js +0 -21
  67. package/dist/web/assets/index-g11aaU-x.css +0 -2
  68. package/dist/web/assets/input-DMu1FA4M.js +0 -41
  69. package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
  70. package/dist/web/assets/react-Bo97Lrzq.js +0 -1
  71. package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
  72. package/dist/web/assets/settings-store-BRFvbsHd.js +0 -1
  73. package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
  74. package/dist/web/assets/sqlite-viewer-BbgWU-v3.js +0 -1
@@ -18,16 +18,18 @@
18
18
  │ │ Hono HTTP Framework (Port 8080) │ │
19
19
  │ ├────────────────────────────────────────────────────────────────┤ │
20
20
  │ │ Routes (src/server/routes/) │ │
21
- │ │ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────┐ │ │
22
- │ │ │ /api/projects │ │ /api/project/:n/ │ │ /api/health │ │ │
23
- │ │ │ (CRUD projects) │ │ (scoped routes) │ │ (status) │ │ │
24
- │ │ └──────────────────┘ └──────────────────┘ └─────────────┘ │ │
21
+ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ │
22
+ │ │ │ /api/projects │ │ /api/project/:n/ │ │ /api/db/* │ │ │
23
+ │ │ │ (CRUD projects) │ │ (scoped routes) │ │ (connections)│ │ │
24
+ │ │ └──────────────────┘ └──────────────────┘ └──────────────┘ │ │
25
25
  │ ├────────────────────────────────────────────────────────────────┤ │
26
26
  │ │ Services (src/services/) │ │
27
27
  │ │ ┌───────────────────────────────────────────────────────────┐│ │
28
28
  │ │ │ ChatService │ GitService │ FileService │ TerminalService ││ │
29
29
  │ │ │ (streaming │ (simple- │ (read/write │ (PTY/shell) ││ │
30
30
  │ │ │ messages) │ git) │ files) │ (Bun.spawn) ││ │
31
+ │ │ │ TableCache │ DbService │ DatabaseAdapterRegistry ││ │
32
+ │ │ │ (metadata) │ (SQLite) │ (SQLite, PostgreSQL adapters) ││ │
31
33
  │ │ └───────────────────────────────────────────────────────────┘│ │
32
34
  │ ├────────────────────────────────────────────────────────────────┤ │
33
35
  │ │ Providers (src/providers/) │ │
@@ -45,8 +47,10 @@
45
47
  │ │ SQLite DB │ │ Git Repos │ │ Session Storage │ │
46
48
  │ │ (config, projs) │ │ (local disk) │ │ (SQLite + SDK) │ │
47
49
  │ │ (session map) │ │ │ │ (session_map, │ │
48
- │ │ (push subs, │ │ │ │ session_logs, │ │
49
- │ │ usage, logs) │ │ │ │ usage_history) │ │
50
+ │ │ (push subs, │ │ Connections: │ │ session_logs, │ │
51
+ │ │ usage, logs) │ │ • SQLite files │ │ usage_history) │ │
52
+ │ │ (connections) │ │ • PostgreSQL svr │ │ (connections) │ │
53
+ │ │ (table metadata) │ │ via connStr │ │ │ │
50
54
  │ └──────────────────┘ └──────────────────┘ └─────────────────┘ │
51
55
  └──────────────────────────────────────────────────────────────────────┘
52
56
  ↓↑
@@ -119,6 +123,15 @@ POST /api/project/:name/git/commit → Commit
119
123
  GET /api/project/:name/files/tree → Directory tree
120
124
  GET /api/project/:name/files/raw → File content
121
125
  PUT /api/project/:name/files/write → Write file
126
+ GET /api/db/connections → List all connections
127
+ POST /api/db/connections → Create connection (SQLite/PostgreSQL)
128
+ GET /api/db/connections/:id → Get connection (sanitized)
129
+ PUT /api/db/connections/:id → Update connection (toggle readonly, UI-only)
130
+ DELETE /api/db/connections/:id → Delete connection
131
+ GET /api/db/connections/:id/tables → List tables (with sync)
132
+ GET /api/db/connections/:id/tables/:table → Get table schema + data
133
+ POST /api/db/connections/:id/query → Execute query (readonly checked)
134
+ PATCH /api/db/connections/:id/cell → Update cell value (single)
122
135
  WS /ws/project/:name/chat/:sessionId → Chat streaming
123
136
  WS /ws/project/:name/terminal/:id → Terminal I/O
124
137
  ```
@@ -140,7 +153,8 @@ WS /ws/project/:name/terminal/:id → Terminal I/O
140
153
  |---------|---------|-------------|
141
154
  | **ChatService** | Session management, message streaming | createSession, streamMessage, getHistory |
142
155
  | **ConfigService** | Config loading (YAML→SQLite migration) | load, save, getToken |
143
- | **DbService** | SQLite persistence (6 tables, WAL) | getDb, openTestDb, schema migrations |
156
+ | **DbService** | SQLite persistence (8 tables, WAL, connections CRUD) | getDb, openTestDb, getConnections, insertConnection, updateConnection, deleteConnection, getTableCache |
157
+ | **TableCacheService** | Cache table metadata, search tables | syncTables, searchTables, invalidateCache |
144
158
  | **GitService** | Git command execution | status, diff, commit, stage, branch |
145
159
  | **FileService** | File operations with validation | read, write, tree, delete, mkdir |
146
160
  | **TerminalService** | PTY lifecycle, shell spawning | spawn, write, kill |
@@ -151,6 +165,9 @@ WS /ws/project/:name/terminal/:id → Terminal I/O
151
165
  | **ProviderRegistry** | AI provider routing | getDefault, send (delegates) |
152
166
  | **CloudflaredService** | Download cloudflared binary | ensureCloudflared, getCloudflaredPath |
153
167
  | **TunnelService** | Cloudflare Quick Tunnel lifecycle | startTunnel, stopTunnel, getTunnelUrl |
168
+ | **DatabaseAdapterRegistry** | Register/retrieve DB adapters (extensible) | registerAdapter, getAdapter |
169
+ | **SQLiteAdapter** | SQLite connection, query execution, readonly checks | testConnection, getTables, getTableSchema, getTableData, executeQuery, updateCell |
170
+ | **PostgresAdapter** | PostgreSQL connection, query execution, readonly checks | testConnection, getTables, getTableSchema, getTableData, executeQuery, updateCell |
154
171
 
155
172
  **Key Files:** `src/services/*.service.ts`
156
173
 
@@ -544,6 +561,204 @@ UI updates: "src/index.ts" moves from "Unstaged" to "Staged"
544
561
 
545
562
  ---
546
563
 
564
+ ## Database Management (v2.0+)
565
+
566
+ ### Architecture Overview
567
+
568
+ PPM now supports managing external databases (SQLite & PostgreSQL) through a unified adapter pattern:
569
+
570
+ ```
571
+ ┌─────────────────────────────────────────────────────────────────┐
572
+ │ Web UI (React) │
573
+ │ ┌──────────────────────────────────────────────────────────┐ │
574
+ │ │ Database Sidebar │ │
575
+ │ │ • Connection List (with color badges) │ │
576
+ │ │ • Create/Edit Connection Form │ │
577
+ │ │ • Color Picker (WCAG contrast-aware) │ │
578
+ │ │ • Query Execution UI │ │
579
+ │ └──────────────────────────────────────────────────────────┘ │
580
+ └─────────────────┬───────────────────────────────────────────────┘
581
+ │ HTTP REST / WebSocket
582
+ ┌─────────────────┴───────────────────────────────────────────────┐
583
+ │ PPM Server (Hono) │
584
+ │ ┌──────────────────────────────────────────────────────────┐ │
585
+ │ │ /api/db Routes │ │
586
+ │ │ • GET /connections → List all connections │ │
587
+ │ │ • POST /connections → Create connection │ │
588
+ │ │ • GET /connections/:id → Get connection (sanitized) │ │
589
+ │ │ • PUT /connections/:id → Update (readonly toggle) │ │
590
+ │ │ • DELETE /connections/:id → Remove connection │ │
591
+ │ │ • GET /connections/:id/tables → List + sync tables │ │
592
+ │ │ • GET /connections/:id/tables/:tbl → Schema + data │ │
593
+ │ │ • POST /connections/:id/query → Execute query │ │
594
+ │ │ • PATCH /connections/:id/cell → Update cell │ │
595
+ │ └──────────────────────────────────────────────────────────┘ │
596
+ │ ┌──────────────────────────────────────────────────────────┐ │
597
+ │ │ Service Layer │ │
598
+ │ │ • DbService (connection CRUD, caching) │ │
599
+ │ │ • TableCacheService (metadata cache, search) │ │
600
+ │ │ • DatabaseAdapterRegistry (extensible) │ │
601
+ │ └──────────────────────────────────────────────────────────┘ │
602
+ │ ┌──────────────────────────────────────────────────────────┐ │
603
+ │ │ Adapters (Pluggable Pattern) │ │
604
+ │ │ • SQLiteAdapter → Uses `bun:sqlite` for local files │ │
605
+ │ │ • PostgresAdapter → Uses postgres driver for servers │ │
606
+ │ │ • isReadOnlyQuery() → Safety check (CTE-safe regex) │ │
607
+ │ │ • readonly=1 by default (safe-by-default) │ │
608
+ │ └──────────────────────────────────────────────────────────┘ │
609
+ └──────────────────────────────────────────────────────────────────┘
610
+ ↓↑
611
+ ┌────────────────────────────────────────────┐
612
+ │ External Databases │
613
+ │ • SQLite files (path: /path/to/db.db) │
614
+ │ • PostgreSQL servers (connStr: postgres://)│
615
+ └────────────────────────────────────────────┘
616
+ ```
617
+
618
+ ### DatabaseAdapter Pattern (Extensible)
619
+
620
+ **Interface** (`src/types/database.ts`):
621
+ ```typescript
622
+ interface DatabaseAdapter {
623
+ testConnection(config: DbConnectionConfig): Promise<{ ok: boolean; error?: string }>;
624
+ getTables(config: DbConnectionConfig): Promise<DbTableInfo[]>;
625
+ getTableSchema(config: DbConnectionConfig, table: string, schema?: string): Promise<DbColumnInfo[]>;
626
+ getTableData(config: DbConnectionConfig, table: string, opts: {...}): Promise<DbPagedData>;
627
+ executeQuery(config: DbConnectionConfig, sql: string): Promise<DbQueryResult>;
628
+ updateCell(config: DbConnectionConfig, table: string, opts: {...}): Promise<void>;
629
+ }
630
+ ```
631
+
632
+ **Implementations:**
633
+ 1. **SQLiteAdapter** — Local file-based SQLite via `bun:sqlite`
634
+ - testConnection: Opens file, runs pragma check
635
+ - Supports: SELECT, INSERT, UPDATE, DELETE (if writable), CREATE TABLE
636
+
637
+ 2. **PostgresAdapter** — Remote PostgreSQL servers via postgres driver
638
+ - testConnection: Attempts connection with credentials
639
+ - Supports: Full SQL except DDL on readonly connections
640
+
641
+ **Registry Pattern** (`src/services/database/adapter-registry.ts`):
642
+ ```typescript
643
+ registerAdapter("sqlite", new SQLiteAdapter());
644
+ registerAdapter("postgres", new PostgresAdapter());
645
+ // Can be extended: registerAdapter("mysql", new MysqlAdapter());
646
+ ```
647
+
648
+ ### Security Design
649
+
650
+ **Readonly by Default:**
651
+ - All connections created with `readonly = true` in database
652
+ - Default: read-only query execution (safe-by-default)
653
+ - Web UI toggle: Switch to writable (admin decision only)
654
+ - CLI: Cannot disable readonly via command-line (browser only)
655
+
656
+ **Readonly Query Detection:**
657
+ ```typescript
658
+ // isReadOnlyQuery() in src/services/database/readonly-check.ts
659
+ // Checks for: SELECT, PRAGMA, EXPLAIN, WITH (CTE)
660
+ // Rejects: INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, etc.
661
+ // CTE-safe: Handles "WITH AS SELECT" (wraps CTE result check)
662
+ ```
663
+
664
+ **Credential Handling:**
665
+ - Connection credentials stored in SQLite `connections` table as `connection_config` JSON
666
+ - **NEVER** returned in API responses (stripped by `sanitizeConn()` in routes)
667
+ - Only used internally by adapters when executing queries
668
+ - Frontend never sees passwords/connection strings
669
+
670
+ **API Security:**
671
+ - All `/api/db` requests require valid auth token (middleware checked)
672
+ - Connection IDs are numeric (no enumeration risk)
673
+ - Connection color is user-specific (cosmetic only, not sensitive)
674
+
675
+ ### Data Flow: Query Execution
676
+
677
+ ```
678
+ User opens Database tab
679
+
680
+ DatabaseSidebar fetches: GET /api/db/connections
681
+
682
+ ConnectionList displays (sanitized, no credentials)
683
+
684
+ User clicks connection → GET /api/db/connections/:id/tables
685
+
686
+ DbService.getConnections() reads from SQLite
687
+
688
+ TableCacheService.syncTables() calls adapter.getTables()
689
+
690
+ SQLiteAdapter/PostgresAdapter queries database
691
+
692
+ Results cached in table_metadata table
693
+
694
+ UI displays table list + schema
695
+
696
+ User selects table → GET /api/db/connections/:id/tables/:table
697
+
698
+ Adapter.getTableData() executes paginated query
699
+
700
+ Results returned: { columns, rows, total, page, limit }
701
+
702
+ UI renders table grid with pagination
703
+
704
+ User executes custom query → POST /api/db/connections/:id/query
705
+
706
+ isReadOnlyQuery() checks SQL (rejects writes if readonly=true)
707
+
708
+ Adapter.executeQuery() runs SQL
709
+
710
+ Results returned: { columns, rows, rowsAffected, changeType }
711
+
712
+ UI displays results (read-only highlight if mutation was blocked)
713
+ ```
714
+
715
+ ### Connection Storage
716
+
717
+ **SQLite Schema** (in `~/.ppm/ppm.db`):
718
+ ```sql
719
+ CREATE TABLE connections (
720
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
721
+ type TEXT NOT NULL, -- 'sqlite' | 'postgres'
722
+ name TEXT NOT NULL,
723
+ connection_config TEXT NOT NULL, -- JSON: { path, connectionString, ... }
724
+ readonly INTEGER DEFAULT 1, -- 1 = readonly, 0 = writable (UI-only toggle)
725
+ group_name TEXT,
726
+ color TEXT, -- Optional hex color (#3b82f6)
727
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
728
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
729
+ );
730
+
731
+ CREATE TABLE table_metadata (
732
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
733
+ connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
734
+ table_name TEXT NOT NULL,
735
+ schema_name TEXT DEFAULT 'public',
736
+ row_count INTEGER,
737
+ last_synced TEXT,
738
+ UNIQUE(connection_id, table_name, schema_name)
739
+ );
740
+ ```
741
+
742
+ ### CLI Support (ppm db)
743
+
744
+ **Commands** (`src/cli/commands/db-cmd.ts`):
745
+ ```bash
746
+ ppm db connections # List all connections
747
+ ppm db connect # Add new connection (interactive)
748
+ ppm db remove <name> # Delete connection
749
+ ppm db query <name> <sql> # Execute query (respects readonly)
750
+ ppm db tables <name> # List tables
751
+ ppm db schema <name> <table> # Show table schema
752
+ ppm db data <name> <table> # Show table data (paginated)
753
+ ```
754
+
755
+ **CLI Safety:**
756
+ - Always respects readonly flag (cannot override via CLI)
757
+ - Uses same adapter/validation as web UI
758
+ - Table formatting for terminal output
759
+
760
+ ---
761
+
547
762
  ## Deployment Architecture
548
763
 
549
764
  ### Single-Machine Deployment (Current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,355 @@
1
+ import { Command } from "commander";
2
+ import { isReadOnlyQuery } from "../../services/database/readonly-check.ts";
3
+
4
+ const C = {
5
+ reset: "\x1b[0m",
6
+ bold: "\x1b[1m",
7
+ green: "\x1b[32m",
8
+ red: "\x1b[31m",
9
+ yellow: "\x1b[33m",
10
+ cyan: "\x1b[36m",
11
+ dim: "\x1b[2m",
12
+ magenta: "\x1b[35m",
13
+ };
14
+
15
+ function printTable(headers: string[], rows: string[][]): void {
16
+ const colWidths = headers.map((h, i) =>
17
+ Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
18
+ );
19
+ const sep = colWidths.map((w) => "-".repeat(w + 2)).join("+");
20
+ const headerLine = headers.map((h, i) => ` ${h.padEnd(colWidths[i]!)} `).join("|");
21
+ console.log(`+${sep}+`);
22
+ console.log(`|${C.bold}${headerLine}${C.reset}|`);
23
+ console.log(`+${sep}+`);
24
+ for (const row of rows) {
25
+ const line = row.map((cell, i) => ` ${(cell ?? "").padEnd(colWidths[i]!)} `).join("|");
26
+ console.log(`|${line}|`);
27
+ }
28
+ console.log(`+${sep}+`);
29
+ }
30
+
31
+ function formatRows(columns: string[], rows: Record<string, unknown>[], limit = 50): void {
32
+ if (rows.length === 0) {
33
+ console.log(`${C.dim}(no rows)${C.reset}`);
34
+ return;
35
+ }
36
+ const displayRows = rows.slice(0, limit);
37
+ const strRows = displayRows.map((r) =>
38
+ columns.map((c) => {
39
+ const v = r[c];
40
+ if (v === null || v === undefined) return `${C.dim}NULL${C.reset}`;
41
+ const s = String(v);
42
+ return s.length > 60 ? s.slice(0, 57) + "..." : s;
43
+ }),
44
+ );
45
+ printTable(columns, strRows);
46
+ if (rows.length > limit) {
47
+ console.log(`${C.dim}... and ${rows.length - limit} more rows${C.reset}`);
48
+ }
49
+ }
50
+
51
+ /** Parse connection_config JSON and return the connection string or path */
52
+ function parseConfig(row: { type: string; connection_config: string }): { type: string; path?: string; connectionString?: string } {
53
+ const cfg = JSON.parse(row.connection_config);
54
+ return { type: row.type, ...cfg };
55
+ }
56
+
57
+ /** Mask password in postgres connection string: postgresql://user:pass@host → postgresql://user:***@host */
58
+ function maskPassword(connectionString: string): string {
59
+ return connectionString.replace(/(:\/\/[^:]+:)[^@]+(@)/, "$1***$2");
60
+ }
61
+
62
+
63
+ export function registerDbCommands(program: Command): void {
64
+ const db = program.command("db").description("Manage database connections and execute queries");
65
+
66
+ // ── ppm db list ──────────────────────────────────────────────────────
67
+ db.command("list")
68
+ .description("List all saved database connections")
69
+ .action(async () => {
70
+ try {
71
+ const { getConnections } = await import("../../services/db.service.ts");
72
+ const conns = getConnections();
73
+ if (conns.length === 0) {
74
+ console.log(`${C.yellow}No connections saved.${C.reset} Run: ppm db add`);
75
+ return;
76
+ }
77
+ const rows = conns.map((c) => {
78
+ const cfg = parseConfig(c);
79
+ let target = cfg.connectionString ?? cfg.path ?? "-";
80
+ // Mask password in postgres connection strings
81
+ if (cfg.connectionString) target = maskPassword(target);
82
+ const display = target.length > 70 ? target.slice(0, 67) + "..." : target;
83
+ const ro = c.readonly ? `${C.yellow}RO${C.reset}` : `${C.green}RW${C.reset}`;
84
+ return [String(c.id), c.name, c.type, c.group_name ?? "-", ro, display];
85
+ });
86
+ printTable(["ID", "Name", "Type", "Group", "RO", "Connection"], rows);
87
+ } catch (err) {
88
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
89
+ process.exit(1);
90
+ }
91
+ });
92
+
93
+ // ── ppm db add ───────────────────────────────────────────────────────
94
+ db.command("add")
95
+ .description("Add a new database connection")
96
+ .requiredOption("-n, --name <name>", "Connection name (unique)")
97
+ .requiredOption("-t, --type <type>", "Database type: sqlite | postgres")
98
+ .option("-c, --connection-string <url>", "PostgreSQL connection string")
99
+ .option("-f, --file <path>", "SQLite file path (absolute)")
100
+ .option("-g, --group <group>", "Group name")
101
+ .option("--color <color>", "Tab color (hex, e.g. #3b82f6)")
102
+ .action(async (options) => {
103
+ try {
104
+ const { insertConnection } = await import("../../services/db.service.ts");
105
+ const type = options.type as "sqlite" | "postgres";
106
+
107
+ if (!["sqlite", "postgres"].includes(type)) {
108
+ console.error(`${C.red}Error:${C.reset} --type must be 'sqlite' or 'postgres'`);
109
+ process.exit(1);
110
+ }
111
+
112
+ let config: import("../../services/db.service.ts").ConnectionConfig;
113
+ if (type === "postgres") {
114
+ if (!options.connectionString) {
115
+ console.error(`${C.red}Error:${C.reset} PostgreSQL requires --connection-string`);
116
+ process.exit(1);
117
+ }
118
+ config = { type: "postgres", connectionString: options.connectionString };
119
+ } else {
120
+ if (!options.file) {
121
+ console.error(`${C.red}Error:${C.reset} SQLite requires --file (absolute path)`);
122
+ process.exit(1);
123
+ }
124
+ const { resolve } = await import("node:path");
125
+ config = { type: "sqlite", path: resolve(options.file) };
126
+ }
127
+
128
+ const conn = insertConnection(type, options.name, config, options.group, options.color);
129
+ console.log(`${C.green}Added connection:${C.reset} ${conn.name} (${conn.type}) #${conn.id}`);
130
+ } catch (err) {
131
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
132
+ process.exit(1);
133
+ }
134
+ });
135
+
136
+ // ── ppm db remove ────────────────────────────────────────────────────
137
+ db.command("remove <name>")
138
+ .description("Remove a saved connection (by name or ID)")
139
+ .action(async (nameOrId: string) => {
140
+ try {
141
+ const { deleteConnection } = await import("../../services/db.service.ts");
142
+ if (deleteConnection(nameOrId)) {
143
+ console.log(`${C.green}Removed connection:${C.reset} ${nameOrId}`);
144
+ } else {
145
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
146
+ process.exit(1);
147
+ }
148
+ } catch (err) {
149
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
150
+ process.exit(1);
151
+ }
152
+ });
153
+
154
+ // ── ppm db test ──────────────────────────────────────────────────────
155
+ db.command("test <name>")
156
+ .description("Test a saved connection")
157
+ .action(async (nameOrId: string) => {
158
+ try {
159
+ const { resolveConnection } = await import("../../services/db.service.ts");
160
+ const conn = resolveConnection(nameOrId);
161
+ if (!conn) {
162
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
163
+ process.exit(1);
164
+ }
165
+ const cfg = parseConfig(conn);
166
+
167
+ if (conn.type === "postgres") {
168
+ const { postgresService } = await import("../../services/postgres.service.ts");
169
+ const result = await postgresService.testConnection(cfg.connectionString!);
170
+ if (result.ok) {
171
+ console.log(`${C.green}✓${C.reset} Connection successful: ${conn.name}`);
172
+ } else {
173
+ console.error(`${C.red}✗${C.reset} Connection failed: ${result.error}`);
174
+ process.exit(1);
175
+ }
176
+ } else {
177
+ const { existsSync } = await import("node:fs");
178
+ if (existsSync(cfg.path!)) {
179
+ // Try opening the file
180
+ const { sqliteService } = await import("../../services/sqlite.service.ts");
181
+ sqliteService.getTables(cfg.path!, cfg.path!);
182
+ console.log(`${C.green}✓${C.reset} SQLite file accessible: ${conn.name}`);
183
+ } else {
184
+ console.error(`${C.red}✗${C.reset} File not found: ${cfg.path}`);
185
+ process.exit(1);
186
+ }
187
+ }
188
+ } catch (err) {
189
+ console.error(`${C.red}✗${C.reset} Test failed:`, (err as Error).message);
190
+ process.exit(1);
191
+ }
192
+ });
193
+
194
+ // ── ppm db tables ────────────────────────────────────────────────────
195
+ db.command("tables <name>")
196
+ .description("List tables in a database connection")
197
+ .action(async (nameOrId: string) => {
198
+ try {
199
+ const { resolveConnection } = await import("../../services/db.service.ts");
200
+ const conn = resolveConnection(nameOrId);
201
+ if (!conn) {
202
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
203
+ process.exit(1);
204
+ }
205
+ const cfg = parseConfig(conn);
206
+
207
+ if (conn.type === "postgres") {
208
+ const { postgresService } = await import("../../services/postgres.service.ts");
209
+ const tables = await postgresService.getTables(cfg.connectionString!);
210
+ if (tables.length === 0) {
211
+ console.log(`${C.dim}No tables found.${C.reset}`);
212
+ return;
213
+ }
214
+ printTable(
215
+ ["Schema", "Table", "Rows (est.)"],
216
+ tables.map((t) => [t.schema, t.name, String(t.rowCount)]),
217
+ );
218
+ } else {
219
+ const { sqliteService } = await import("../../services/sqlite.service.ts");
220
+ const tables = sqliteService.getTables(cfg.path!, cfg.path!);
221
+ if (tables.length === 0) {
222
+ console.log(`${C.dim}No tables found.${C.reset}`);
223
+ return;
224
+ }
225
+ printTable(
226
+ ["Table", "Rows"],
227
+ tables.map((t) => [t.name, String(t.rowCount)]),
228
+ );
229
+ }
230
+ } catch (err) {
231
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
232
+ process.exit(1);
233
+ }
234
+ });
235
+
236
+ // ── ppm db schema ────────────────────────────────────────────────────
237
+ db.command("schema <name> <table>")
238
+ .description("Show table schema (columns, types, constraints)")
239
+ .option("-s, --schema <schema>", "PostgreSQL schema name", "public")
240
+ .action(async (nameOrId: string, table: string, options: { schema: string }) => {
241
+ try {
242
+ const { resolveConnection } = await import("../../services/db.service.ts");
243
+ const conn = resolveConnection(nameOrId);
244
+ if (!conn) {
245
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
246
+ process.exit(1);
247
+ }
248
+ const cfg = parseConfig(conn);
249
+
250
+ if (conn.type === "postgres") {
251
+ const { postgresService } = await import("../../services/postgres.service.ts");
252
+ const cols = await postgresService.getTableSchema(cfg.connectionString!, table, options.schema);
253
+ printTable(
254
+ ["Column", "Type", "Nullable", "PK", "Default"],
255
+ cols.map((c) => [c.name, c.type, c.nullable ? "YES" : "NO", c.pk ? "PK" : "", c.defaultValue ?? ""]),
256
+ );
257
+ } else {
258
+ const { sqliteService } = await import("../../services/sqlite.service.ts");
259
+ const cols = sqliteService.getTableSchema(cfg.path!, cfg.path!, table);
260
+ printTable(
261
+ ["Column", "Type", "Not Null", "PK", "Default"],
262
+ cols.map((c) => [c.name, c.type, c.notnull ? "YES" : "NO", c.pk ? "PK" : "", c.dflt_value ?? ""]),
263
+ );
264
+ }
265
+ } catch (err) {
266
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
267
+ process.exit(1);
268
+ }
269
+ });
270
+
271
+ // ── ppm db data ──────────────────────────────────────────────────────
272
+ db.command("data <name> <table>")
273
+ .description("View table data (paginated)")
274
+ .option("-p, --page <page>", "Page number", "1")
275
+ .option("-l, --limit <limit>", "Rows per page", "50")
276
+ .option("--order <column>", "Order by column")
277
+ .option("--desc", "Descending order")
278
+ .option("-s, --schema <schema>", "PostgreSQL schema name", "public")
279
+ .action(async (nameOrId: string, table: string, options) => {
280
+ try {
281
+ const { resolveConnection } = await import("../../services/db.service.ts");
282
+ const conn = resolveConnection(nameOrId);
283
+ if (!conn) {
284
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
285
+ process.exit(1);
286
+ }
287
+ const cfg = parseConfig(conn);
288
+ const page = parseInt(options.page, 10);
289
+ const limit = parseInt(options.limit, 10);
290
+ const orderDir = options.desc ? "DESC" as const : "ASC" as const;
291
+
292
+ if (conn.type === "postgres") {
293
+ const { postgresService } = await import("../../services/postgres.service.ts");
294
+ const result = await postgresService.getTableData(
295
+ cfg.connectionString!, table, options.schema, page, limit, options.order, orderDir,
296
+ );
297
+ console.log(`${C.cyan}${table}${C.reset} — page ${result.page}, ${result.total} total rows\n`);
298
+ formatRows(result.columns, result.rows, limit);
299
+ } else {
300
+ const { sqliteService } = await import("../../services/sqlite.service.ts");
301
+ const result = sqliteService.getTableData(
302
+ cfg.path!, cfg.path!, table, page, limit, options.order, orderDir,
303
+ );
304
+ console.log(`${C.cyan}${table}${C.reset} — page ${result.page}, ${result.total} total rows\n`);
305
+ formatRows(result.columns, result.rows, limit);
306
+ }
307
+ } catch (err) {
308
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
309
+ process.exit(1);
310
+ }
311
+ });
312
+
313
+ // ── ppm db query ─────────────────────────────────────────────────────
314
+ db.command("query <name> <sql>")
315
+ .description("Execute a SQL query against a saved connection")
316
+ .action(async (nameOrId: string, sql: string) => {
317
+ try {
318
+ const { resolveConnection } = await import("../../services/db.service.ts");
319
+ const conn = resolveConnection(nameOrId);
320
+ if (!conn) {
321
+ console.error(`${C.red}Connection not found:${C.reset} ${nameOrId}`);
322
+ process.exit(1);
323
+ }
324
+ const cfg = parseConfig(conn);
325
+
326
+ // Enforce readonly — CLI cannot disable this, only the web UI can toggle it
327
+ if (conn.readonly && !isReadOnlyQuery(sql)) {
328
+ console.error(`${C.red}Error:${C.reset} Connection "${conn.name}" is readonly — only SELECT queries allowed.`);
329
+ console.error(` To allow writes, toggle the readonly switch in the PPM web UI.`);
330
+ process.exit(1);
331
+ }
332
+
333
+ if (conn.type === "postgres") {
334
+ const { postgresService } = await import("../../services/postgres.service.ts");
335
+ const result = await postgresService.executeQuery(cfg.connectionString!, sql);
336
+ if (result.changeType === "select") {
337
+ formatRows(result.columns, result.rows);
338
+ } else {
339
+ console.log(`${C.green}OK${C.reset} — ${result.rowsAffected} row(s) affected`);
340
+ }
341
+ } else {
342
+ const { sqliteService } = await import("../../services/sqlite.service.ts");
343
+ const result = sqliteService.executeQuery(cfg.path!, cfg.path!, sql);
344
+ if (result.changeType === "select") {
345
+ formatRows(result.columns, result.rows);
346
+ } else {
347
+ console.log(`${C.green}OK${C.reset} — ${result.rowsAffected} row(s) affected`);
348
+ }
349
+ }
350
+ } catch (err) {
351
+ console.error(`${C.red}Error:${C.reset}`, (err as Error).message);
352
+ process.exit(1);
353
+ }
354
+ });
355
+ }
@@ -10,6 +10,8 @@ import { tunnelRoutes } from "./routes/tunnel.ts";
10
10
  import { staticRoutes } from "./routes/static.ts";
11
11
  import { projectScopedRouter } from "./routes/project-scoped.ts";
12
12
  import { postgresRoutes } from "./routes/postgres.ts";
13
+ import { databaseRoutes } from "./routes/database.ts";
14
+ import { initAdapters } from "../services/database/init-adapters.ts";
13
15
  import { terminalWebSocket } from "./ws/terminal.ts";
14
16
  import { chatWebSocket } from "./ws/chat.ts";
15
17
  import { ok, err } from "../types/api.ts";
@@ -57,6 +59,9 @@ async function setupLogFile() {
57
59
  });
58
60
  }
59
61
 
62
+ // Register database adapters at module load time
63
+ initAdapters();
64
+
60
65
  export const app = new Hono();
61
66
 
62
67
  // CORS for dev
@@ -185,6 +190,7 @@ app.route("/api/push", pushRoutes);
185
190
  app.route("/api/projects", projectRoutes);
186
191
  app.route("/api/project/:projectName", projectScopedRouter);
187
192
  app.route("/api/postgres", postgresRoutes);
193
+ app.route("/api/db", databaseRoutes);
188
194
 
189
195
  // Static files / SPA fallback (non-API routes)
190
196
  app.route("/", staticRoutes);