@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.
- package/CHANGELOG.md +16 -0
- package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
- package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
- package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
- package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
- package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
- package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
- package/dist/web/assets/index-DhuAmTQ1.js +21 -0
- package/dist/web/assets/index-aIGuIMQ8.css +2 -0
- package/dist/web/assets/input-CCCPR1s4.js +41 -0
- package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
- package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
- package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
- package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
- package/dist/web/assets/react-l9v2XLcs.js +1 -0
- package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
- package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
- package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
- package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-3tDV4RCn.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +41 -14
- package/docs/project-roadmap.md +31 -6
- package/docs/system-architecture.md +222 -7
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +355 -0
- package/src/server/index.ts +6 -0
- package/src/server/routes/database.ts +259 -0
- package/src/server/routes/settings.ts +33 -0
- package/src/services/database/adapter-registry.ts +13 -0
- package/src/services/database/init-adapters.ts +9 -0
- package/src/services/database/postgres-adapter.ts +42 -0
- package/src/services/database/readonly-check.ts +17 -0
- package/src/services/database/sqlite-adapter.ts +55 -0
- package/src/services/db.service.ts +173 -2
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +11 -1
- package/src/web/components/database/connection-color-picker.tsx +67 -0
- package/src/web/components/database/connection-form-dialog.tsx +234 -0
- package/src/web/components/database/connection-list.tsx +208 -0
- package/src/web/components/database/database-sidebar.tsx +100 -0
- package/src/web/components/database/use-connections.ts +99 -0
- package/src/web/components/layout/command-palette.tsx +57 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/sidebar.tsx +6 -1
- package/src/web/components/postgres/postgres-viewer.tsx +12 -3
- package/src/web/components/postgres/use-postgres.ts +57 -21
- package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
- package/src/web/components/sqlite/use-sqlite.ts +21 -12
- package/src/web/hooks/use-global-keybindings.ts +74 -14
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/keybindings-store.ts +192 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/dist/web/assets/api-client-DPWUomlf.js +0 -1
- package/dist/web/assets/code-editor-soN1frMc.js +0 -1
- package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
- package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
- package/dist/web/assets/index-CmrE0Xoy.js +0 -21
- package/dist/web/assets/index-g11aaU-x.css +0 -2
- package/dist/web/assets/input-DMu1FA4M.js +0 -41
- package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
- package/dist/web/assets/react-Bo97Lrzq.js +0 -1
- package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
- package/dist/web/assets/settings-store-BRFvbsHd.js +0 -1
- package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
- 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/
|
|
23
|
-
│ │ │ (CRUD projects) │ │ (scoped routes) │ │
|
|
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, │ │
|
|
49
|
-
│ │ usage, logs) │ │
|
|
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 (
|
|
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
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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);
|