tide-commander 1.87.0 → 1.88.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/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BXQh_FI_.js} +1 -1
- package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BevC86-H.js} +1 -1
- package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-OTv1pMsh.js} +1 -1
- package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-oLpkSRWn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-Ch6-Ql2A.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-wOwZdO76.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-ChVRBoAO.js} +1 -1
- package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-BAQxs9VC.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CHaNJBJW.js → IntegrationsPanel-YFcOTDdT.js} +2 -2
- package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-CSAEl5nL.js} +1 -1
- package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-Bgu6BN93.js} +1 -1
- package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-D0iQKsKn.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-SDm4CHQ_.js} +1 -1
- package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-kZMJfRRF.js} +1 -1
- package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-CcMFDU1A.js} +1 -1
- package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DKT8TwSH.js} +1 -1
- package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-DKfic48e.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BfSS7J4H.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-C8zZf9rr.js} +2 -2
- package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BsEbQzL1.js} +1 -1
- package/dist/assets/{index-DHHRkTG1.js → index-BW6pasQS.js} +1 -1
- package/dist/assets/{index-DSvJOrb7.js → index-Bm2GprIp.js} +2 -2
- package/dist/assets/{index-BoORE9Q1.js → index-Cn6pTvu9.js} +1 -1
- package/dist/assets/{index-BYVHgVEo.js → index-Cq-zN7dW.js} +1 -1
- package/dist/assets/{index-BtJyOo4p.js → index-CrveGM3a.js} +1 -1
- package/dist/assets/index-DBbK5bs6.js +8 -0
- package/dist/assets/index-DeGYOmjB.js +2 -0
- package/dist/assets/{index-DRGyDtmm.js → index-JfilXCJq.js} +1 -1
- package/dist/assets/{index-BxaEkSIx.js → index-NPF6_aDv.js} +3 -3
- package/dist/assets/{main-kpU9m5LW.css → main-Cm8LaUxX.css} +1 -1
- package/dist/assets/main-CwfJmt2N.js +209 -0
- package/dist/assets/{web-DS0FHmg8.js → web-CN0zYUhV.js} +1 -1
- package/dist/assets/{web-D3zCwsS9.js → web-Dik5OZ1r.js} +1 -1
- package/dist/assets/{web-DEq3Te_H.js → web-JhU3bMzp.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/data/index.js +56 -2
- package/dist/src/packages/server/index.js +2 -1
- package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
- package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +113 -0
- package/dist/src/packages/server/routes/database.js +221 -0
- package/dist/src/packages/server/routes/files.js +128 -18
- package/dist/src/packages/server/routes/index.js +2 -0
- package/dist/src/packages/server/services/building-service.js +41 -0
- package/dist/src/packages/server/services/database-service.js +61 -9
- package/dist/src/packages/server/services/index.js +1 -0
- package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
- package/dist/src/packages/server/websocket/handler.js +2 -1
- package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
- package/package.json +3 -1
- package/dist/assets/index-BOr_tbLK.js +0 -2
- package/dist/assets/index-Co7njQ0Q.js +0 -8
- package/dist/assets/main-BrZe9Zbd.js +0 -201
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Routes
|
|
3
|
+
* REST API endpoints for exploring database buildings.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the WebSocket database handler operations (test/list/schema/query)
|
|
6
|
+
* over plain HTTP so curl-based agent skills can use them.
|
|
7
|
+
*/
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
import { databaseService, buildingService } from '../services/index.js';
|
|
10
|
+
import { createLogger } from '../utils/logger.js';
|
|
11
|
+
const log = createLogger('DatabaseRoutes');
|
|
12
|
+
const router = Router();
|
|
13
|
+
const MAX_QUERY_LIMIT = 10000;
|
|
14
|
+
const DEFAULT_QUERY_LIMIT = 1000;
|
|
15
|
+
function getDatabaseBuilding(buildingId) {
|
|
16
|
+
const building = buildingService.getBuilding(buildingId);
|
|
17
|
+
if (!building || building.type !== 'database' || !building.database) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return building;
|
|
21
|
+
}
|
|
22
|
+
function getConnection(buildingId, connectionId) {
|
|
23
|
+
const building = getDatabaseBuilding(buildingId);
|
|
24
|
+
if (!building)
|
|
25
|
+
return null;
|
|
26
|
+
return building.database.connections.find(c => c.id === connectionId) || null;
|
|
27
|
+
}
|
|
28
|
+
function redactConnection(c) {
|
|
29
|
+
return {
|
|
30
|
+
id: c.id,
|
|
31
|
+
name: c.name,
|
|
32
|
+
engine: c.engine,
|
|
33
|
+
host: c.host,
|
|
34
|
+
port: c.port,
|
|
35
|
+
username: c.username,
|
|
36
|
+
database: c.database,
|
|
37
|
+
filepath: c.filepath,
|
|
38
|
+
ssl: c.ssl,
|
|
39
|
+
hasPassword: Boolean(c.password),
|
|
40
|
+
ssh: c.ssh
|
|
41
|
+
? { enabled: c.ssh.enabled, host: c.ssh.host, port: c.ssh.port, username: c.ssh.username }
|
|
42
|
+
: undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// GET /api/database/buildings - list all database buildings (redacted)
|
|
46
|
+
router.get('/buildings', (_req, res) => {
|
|
47
|
+
const buildings = buildingService.getBuildings()
|
|
48
|
+
.filter(b => b.type === 'database' && b.database)
|
|
49
|
+
.map(b => ({
|
|
50
|
+
id: b.id,
|
|
51
|
+
name: b.name,
|
|
52
|
+
activeConnectionId: b.database.activeConnectionId,
|
|
53
|
+
activeDatabase: b.database.activeDatabase,
|
|
54
|
+
connections: b.database.connections.map(redactConnection),
|
|
55
|
+
}));
|
|
56
|
+
res.json({ buildings });
|
|
57
|
+
});
|
|
58
|
+
// GET /api/database/buildings/:buildingId - one database building (redacted)
|
|
59
|
+
router.get('/buildings/:buildingId', (req, res) => {
|
|
60
|
+
const building = getDatabaseBuilding(String(req.params.buildingId));
|
|
61
|
+
if (!building) {
|
|
62
|
+
res.status(404).json({ error: 'Database building not found' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
res.json({
|
|
66
|
+
id: building.id,
|
|
67
|
+
name: building.name,
|
|
68
|
+
activeConnectionId: building.database.activeConnectionId,
|
|
69
|
+
activeDatabase: building.database.activeDatabase,
|
|
70
|
+
connections: building.database.connections.map(redactConnection),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// POST /api/database/buildings/:buildingId/connections/:connectionId/test
|
|
74
|
+
router.post('/buildings/:buildingId/connections/:connectionId/test', async (req, res) => {
|
|
75
|
+
const buildingId = String(req.params.buildingId);
|
|
76
|
+
const connectionId = String(req.params.connectionId);
|
|
77
|
+
const connection = getConnection(buildingId, connectionId);
|
|
78
|
+
if (!connection) {
|
|
79
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const result = await databaseService.testConnection(connection);
|
|
84
|
+
res.json(result);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
88
|
+
log.error(`testConnection failed for ${buildingId}/${connectionId}:`, message);
|
|
89
|
+
res.status(500).json({ success: false, error: message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// GET /api/database/buildings/:buildingId/connections/:connectionId/databases
|
|
93
|
+
router.get('/buildings/:buildingId/connections/:connectionId/databases', async (req, res) => {
|
|
94
|
+
const buildingId = String(req.params.buildingId);
|
|
95
|
+
const connectionId = String(req.params.connectionId);
|
|
96
|
+
const connection = getConnection(buildingId, connectionId);
|
|
97
|
+
if (!connection) {
|
|
98
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const databases = await databaseService.listDatabases(connection);
|
|
103
|
+
res.json({ databases });
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
107
|
+
log.error(`listDatabases failed for ${buildingId}/${connectionId}:`, message);
|
|
108
|
+
res.status(500).json({ error: message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables
|
|
112
|
+
router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables', async (req, res) => {
|
|
113
|
+
const buildingId = String(req.params.buildingId);
|
|
114
|
+
const connectionId = String(req.params.connectionId);
|
|
115
|
+
const database = String(req.params.database);
|
|
116
|
+
const connection = getConnection(buildingId, connectionId);
|
|
117
|
+
if (!connection) {
|
|
118
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const tables = await databaseService.listTables(connection, database);
|
|
123
|
+
res.json({ database, tables });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
127
|
+
log.error(`listTables failed for ${buildingId}/${connectionId}/${database}:`, message);
|
|
128
|
+
res.status(500).json({ error: message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
// GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/columns
|
|
132
|
+
// Lean variant of /schema — returns only the column list (no indexes / foreign keys).
|
|
133
|
+
router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/columns', async (req, res) => {
|
|
134
|
+
const buildingId = String(req.params.buildingId);
|
|
135
|
+
const connectionId = String(req.params.connectionId);
|
|
136
|
+
const database = String(req.params.database);
|
|
137
|
+
const table = String(req.params.table);
|
|
138
|
+
const connection = getConnection(buildingId, connectionId);
|
|
139
|
+
if (!connection) {
|
|
140
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const { columns } = await databaseService.getTableSchema(connection, database, table);
|
|
145
|
+
res.json({ database, table, columns });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
149
|
+
log.error(`describe (columns) failed for ${buildingId}/${connectionId}/${database}/${table}:`, message);
|
|
150
|
+
res.status(500).json({ error: message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// GET /api/database/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/schema
|
|
154
|
+
router.get('/buildings/:buildingId/connections/:connectionId/databases/:database/tables/:table/schema', async (req, res) => {
|
|
155
|
+
const buildingId = String(req.params.buildingId);
|
|
156
|
+
const connectionId = String(req.params.connectionId);
|
|
157
|
+
const database = String(req.params.database);
|
|
158
|
+
const table = String(req.params.table);
|
|
159
|
+
const connection = getConnection(buildingId, connectionId);
|
|
160
|
+
if (!connection) {
|
|
161
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const schema = await databaseService.getTableSchema(connection, database, table);
|
|
166
|
+
res.json({ database, table, ...schema });
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
170
|
+
log.error(`getTableSchema failed for ${buildingId}/${connectionId}/${database}/${table}:`, message);
|
|
171
|
+
res.status(500).json({ error: message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// POST /api/database/buildings/:buildingId/connections/:connectionId/query
|
|
175
|
+
// Body: { database: string, query: string, limit?: number, recordHistory?: boolean }
|
|
176
|
+
router.post('/buildings/:buildingId/connections/:connectionId/query', async (req, res) => {
|
|
177
|
+
const buildingId = String(req.params.buildingId);
|
|
178
|
+
const connectionId = String(req.params.connectionId);
|
|
179
|
+
const { database, query, limit, recordHistory } = req.body ?? {};
|
|
180
|
+
if (typeof database !== 'string' || !database) {
|
|
181
|
+
res.status(400).json({ error: 'Body field "database" is required' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
185
|
+
res.status(400).json({ error: 'Body field "query" is required' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const connection = getConnection(buildingId, connectionId);
|
|
189
|
+
if (!connection) {
|
|
190
|
+
res.status(404).json({ error: 'Connection not found' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const effectiveLimit = Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : DEFAULT_QUERY_LIMIT), MAX_QUERY_LIMIT);
|
|
194
|
+
try {
|
|
195
|
+
const result = await databaseService.executeQuery(connection, database, query, effectiveLimit);
|
|
196
|
+
if (recordHistory && result.status === 'success') {
|
|
197
|
+
databaseService.addToHistory(buildingId, result);
|
|
198
|
+
}
|
|
199
|
+
const status = result.status === 'success' ? 200 : 400;
|
|
200
|
+
res.status(status).json(result);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
204
|
+
log.error(`executeQuery failed for ${buildingId}/${connectionId}:`, message);
|
|
205
|
+
res.status(500).json({ status: 'error', error: message });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// GET /api/database/buildings/:buildingId/history?limit=100
|
|
209
|
+
router.get('/buildings/:buildingId/history', (req, res) => {
|
|
210
|
+
const buildingId = String(req.params.buildingId);
|
|
211
|
+
const building = getDatabaseBuilding(buildingId);
|
|
212
|
+
if (!building) {
|
|
213
|
+
res.status(404).json({ error: 'Database building not found' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const rawLimit = Number(req.query.limit);
|
|
217
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 500) : 100;
|
|
218
|
+
const history = databaseService.getHistory(buildingId, limit);
|
|
219
|
+
res.json({ history });
|
|
220
|
+
});
|
|
221
|
+
export default router;
|
|
@@ -41,6 +41,113 @@ export function resolveAndValidateFilePath(rawPath, baseDir, fallbackBaseDir = p
|
|
|
41
41
|
}
|
|
42
42
|
return { ok: true, path: path.resolve(effectiveBase, rawPath) };
|
|
43
43
|
}
|
|
44
|
+
// Cache of (requested path → resolved absolute path) for successful fallback
|
|
45
|
+
// resolutions. Avoids repeating the walk-up search for the same stale path.
|
|
46
|
+
const resolvedPathCache = new Map();
|
|
47
|
+
const RESOLVED_CACHE_MAX = 500;
|
|
48
|
+
function rememberResolution(key, found) {
|
|
49
|
+
if (resolvedPathCache.size >= RESOLVED_CACHE_MAX) {
|
|
50
|
+
const firstKey = resolvedPathCache.keys().next().value;
|
|
51
|
+
if (firstKey !== undefined)
|
|
52
|
+
resolvedPathCache.delete(firstKey);
|
|
53
|
+
}
|
|
54
|
+
resolvedPathCache.set(key, found);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a requested file path to an existing file on disk, with fallbacks.
|
|
58
|
+
* Tries (in order):
|
|
59
|
+
* 1. The path as resolved by resolveAndValidateFilePath()
|
|
60
|
+
* 2. A previously-cached resolution for the same requested key
|
|
61
|
+
* 3. The path's tail anchored at baseDir and each of its ancestors — recovers
|
|
62
|
+
* stale absolute paths and relative paths that target an ancestor repo
|
|
63
|
+
* 4. The path's tail anchored at git's toplevel discovered from baseDir
|
|
64
|
+
* On miss, returns the absolute path requested AND the list of paths tried so
|
|
65
|
+
* the caller can surface a clear, debuggable error.
|
|
66
|
+
*/
|
|
67
|
+
function findFileWithFallbacks(rawPath, baseDir) {
|
|
68
|
+
if (!rawPath) {
|
|
69
|
+
return { ok: false, status: 400, error: 'Missing path parameter' };
|
|
70
|
+
}
|
|
71
|
+
const resolution = resolveAndValidateFilePath(rawPath, baseDir);
|
|
72
|
+
if (!resolution.ok) {
|
|
73
|
+
return resolution;
|
|
74
|
+
}
|
|
75
|
+
const tried = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const tryCandidate = (p) => {
|
|
78
|
+
if (seen.has(p))
|
|
79
|
+
return null;
|
|
80
|
+
seen.add(p);
|
|
81
|
+
tried.push(p);
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(p) && !fs.statSync(p).isDirectory())
|
|
84
|
+
return p;
|
|
85
|
+
}
|
|
86
|
+
catch { /* permission denied, broken symlink — keep trying */ }
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
const direct = tryCandidate(resolution.path);
|
|
90
|
+
if (direct)
|
|
91
|
+
return { ok: true, path: direct };
|
|
92
|
+
const cached = resolvedPathCache.get(rawPath);
|
|
93
|
+
if (cached) {
|
|
94
|
+
const hit = tryCandidate(cached);
|
|
95
|
+
if (hit)
|
|
96
|
+
return { ok: true, path: hit };
|
|
97
|
+
resolvedPathCache.delete(rawPath);
|
|
98
|
+
}
|
|
99
|
+
const absBase = baseDir && path.isAbsolute(baseDir) ? baseDir : null;
|
|
100
|
+
if (absBase) {
|
|
101
|
+
// Strip leading slashes so absolute and relative requested paths share one
|
|
102
|
+
// segment list. Walking the tail anchors `<a>/<b>/<c>` not just at baseDir
|
|
103
|
+
// but also at `<b>/<c>` and `<c>` against each ancestor — the file is
|
|
104
|
+
// found regardless of which slice of the path moved.
|
|
105
|
+
const tailSegments = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
|
|
106
|
+
let cur = absBase;
|
|
107
|
+
// Bound the climb so a deep baseDir doesn't search the whole filesystem.
|
|
108
|
+
for (let depth = 0; depth < 12; depth++) {
|
|
109
|
+
for (let i = 0; i < tailSegments.length; i++) {
|
|
110
|
+
const candidate = path.join(cur, ...tailSegments.slice(i));
|
|
111
|
+
const hit = tryCandidate(candidate);
|
|
112
|
+
if (hit) {
|
|
113
|
+
rememberResolution(rawPath, hit);
|
|
114
|
+
return { ok: true, path: hit };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const parent = path.dirname(cur);
|
|
118
|
+
if (parent === cur)
|
|
119
|
+
break;
|
|
120
|
+
cur = parent;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
if (fs.existsSync(absBase)) {
|
|
124
|
+
const gitTop = execSync('git rev-parse --show-toplevel', {
|
|
125
|
+
cwd: absBase,
|
|
126
|
+
encoding: 'utf-8',
|
|
127
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
+
}).trim();
|
|
129
|
+
if (gitTop) {
|
|
130
|
+
for (let i = 0; i < tailSegments.length; i++) {
|
|
131
|
+
const candidate = path.join(gitTop, ...tailSegments.slice(i));
|
|
132
|
+
const hit = tryCandidate(candidate);
|
|
133
|
+
if (hit) {
|
|
134
|
+
rememberResolution(rawPath, hit);
|
|
135
|
+
return { ok: true, path: hit };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch { /* baseDir is not in a git repo — skip */ }
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 404,
|
|
146
|
+
error: 'File not found',
|
|
147
|
+
requested: resolution.path,
|
|
148
|
+
tried,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
44
151
|
// Prevent browser from caching git-related GET responses (status, diff, branch, etc.)
|
|
45
152
|
// Without this, browsers may serve stale cached data — e.g. deleted files still appearing.
|
|
46
153
|
router.use('/git-*path', (_req, res, next) => {
|
|
@@ -51,16 +158,17 @@ router.use('/git-*path', (_req, res, next) => {
|
|
|
51
158
|
// GET /api/files/read - Read file contents
|
|
52
159
|
router.get('/read', async (req, res) => {
|
|
53
160
|
try {
|
|
54
|
-
const resolution =
|
|
161
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
55
162
|
if (!resolution.ok) {
|
|
56
|
-
|
|
163
|
+
const body = { error: resolution.error };
|
|
164
|
+
if (resolution.requested)
|
|
165
|
+
body.path = resolution.requested;
|
|
166
|
+
if (resolution.tried)
|
|
167
|
+
body.triedRoots = resolution.tried;
|
|
168
|
+
res.status(resolution.status).json(body);
|
|
57
169
|
return;
|
|
58
170
|
}
|
|
59
171
|
const filePath = resolution.path;
|
|
60
|
-
if (!fs.existsSync(filePath)) {
|
|
61
|
-
res.status(404).json({ error: 'File not found', path: filePath });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
172
|
const stats = fs.statSync(filePath);
|
|
65
173
|
if (stats.isDirectory()) {
|
|
66
174
|
res.status(400).json({ error: 'Path is a directory', path: filePath });
|
|
@@ -190,16 +298,17 @@ router.get('/exists', async (req, res) => {
|
|
|
190
298
|
// GET /api/files/info - Get file info without content
|
|
191
299
|
router.get('/info', async (req, res) => {
|
|
192
300
|
try {
|
|
193
|
-
const resolution =
|
|
301
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
194
302
|
if (!resolution.ok) {
|
|
195
|
-
|
|
303
|
+
const body = { error: resolution.error };
|
|
304
|
+
if (resolution.requested)
|
|
305
|
+
body.path = resolution.requested;
|
|
306
|
+
if (resolution.tried)
|
|
307
|
+
body.triedRoots = resolution.tried;
|
|
308
|
+
res.status(resolution.status).json(body);
|
|
196
309
|
return;
|
|
197
310
|
}
|
|
198
311
|
const filePath = resolution.path;
|
|
199
|
-
if (!fs.existsSync(filePath)) {
|
|
200
|
-
res.status(404).json({ error: 'File not found', path: filePath });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
312
|
const stats = fs.statSync(filePath);
|
|
204
313
|
if (stats.isDirectory()) {
|
|
205
314
|
res.status(400).json({ error: 'Path is a directory', path: filePath });
|
|
@@ -223,17 +332,18 @@ router.get('/info', async (req, res) => {
|
|
|
223
332
|
// GET /api/files/binary - Read binary file (for images, PDFs, downloads)
|
|
224
333
|
router.get('/binary', async (req, res) => {
|
|
225
334
|
try {
|
|
226
|
-
const resolution =
|
|
335
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
227
336
|
if (!resolution.ok) {
|
|
228
|
-
|
|
337
|
+
const body = { error: resolution.error };
|
|
338
|
+
if (resolution.requested)
|
|
339
|
+
body.path = resolution.requested;
|
|
340
|
+
if (resolution.tried)
|
|
341
|
+
body.triedRoots = resolution.tried;
|
|
342
|
+
res.status(resolution.status).json(body);
|
|
229
343
|
return;
|
|
230
344
|
}
|
|
231
345
|
const filePath = resolution.path;
|
|
232
346
|
const download = req.query.download === 'true';
|
|
233
|
-
if (!fs.existsSync(filePath)) {
|
|
234
|
-
res.status(404).json({ error: 'File not found', path: filePath });
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
347
|
const stats = fs.statSync(filePath);
|
|
238
348
|
if (stats.isDirectory()) {
|
|
239
349
|
res.status(400).json({ error: 'Path is a directory', path: filePath });
|
|
@@ -23,6 +23,7 @@ import integrationRouter from './integration-routes.js';
|
|
|
23
23
|
import eventRouter from './event-routes.js';
|
|
24
24
|
import workflowRouter from './workflow-routes.js';
|
|
25
25
|
import sessionsRouter from './sessions.js';
|
|
26
|
+
import databaseRouter from './database.js';
|
|
26
27
|
import { getPlugins } from '../integrations/integration-registry.js';
|
|
27
28
|
const router = Router();
|
|
28
29
|
// Health check
|
|
@@ -48,6 +49,7 @@ router.use('/integrations', integrationRouter);
|
|
|
48
49
|
router.use('/events', eventRouter);
|
|
49
50
|
router.use('/workflows', workflowRouter);
|
|
50
51
|
router.use('/sessions', sessionsRouter);
|
|
52
|
+
router.use('/database', databaseRouter);
|
|
51
53
|
// Integration plugin routes (e.g. /api/slack/*, /api/documents/*, /api/jira/*)
|
|
52
54
|
// Uses lazy lookup so plugins can be registered after route setup
|
|
53
55
|
router.use((req, res, next) => {
|
|
@@ -10,6 +10,7 @@ import { createLogger } from '../utils/index.js';
|
|
|
10
10
|
import * as pm2Service from './pm2-service.js';
|
|
11
11
|
import * as dockerService from './docker-service.js';
|
|
12
12
|
import * as terminalService from './terminal-service.js';
|
|
13
|
+
import * as databaseService from './database-service.js';
|
|
13
14
|
const log = createLogger('BuildingService');
|
|
14
15
|
const execAsync = promisify(exec);
|
|
15
16
|
/**
|
|
@@ -945,7 +946,18 @@ export function cleanupAllTerminals() {
|
|
|
945
946
|
export async function handleBuildingSync(newBuildings, _broadcast) {
|
|
946
947
|
const oldBuildings = loadBuildings();
|
|
947
948
|
const oldBuildingsMap = new Map(oldBuildings.map(b => [b.id, b]));
|
|
949
|
+
const newBuildingIds = new Set(newBuildings.map(b => b.id));
|
|
948
950
|
log.log(`handleBuildingSync called with ${newBuildings.length} buildings`);
|
|
951
|
+
// Tear down database tunnels for buildings that were entirely deleted.
|
|
952
|
+
for (const oldBuilding of oldBuildings) {
|
|
953
|
+
if (newBuildingIds.has(oldBuilding.id))
|
|
954
|
+
continue;
|
|
955
|
+
if (oldBuilding.type !== 'database')
|
|
956
|
+
continue;
|
|
957
|
+
for (const conn of oldBuilding.database?.connections || []) {
|
|
958
|
+
await databaseService.closeConnection(conn.id);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
949
961
|
for (const newBuilding of newBuildings) {
|
|
950
962
|
const oldBuilding = oldBuildingsMap.get(newBuilding.id);
|
|
951
963
|
// Check if this is a PM2-enabled building
|
|
@@ -978,6 +990,35 @@ export async function handleBuildingSync(newBuildings, _broadcast) {
|
|
|
978
990
|
}
|
|
979
991
|
}
|
|
980
992
|
}
|
|
993
|
+
// Database connection changes — tear down tunnels for connections that
|
|
994
|
+
// were removed or whose SSH config materially changed.
|
|
995
|
+
if (oldBuilding && oldBuilding.type === 'database') {
|
|
996
|
+
const oldConns = oldBuilding.database?.connections || [];
|
|
997
|
+
const newConns = newBuilding.type === 'database'
|
|
998
|
+
? (newBuilding.database?.connections || [])
|
|
999
|
+
: [];
|
|
1000
|
+
const newConnsById = new Map(newConns.map(c => [c.id, c]));
|
|
1001
|
+
for (const oldConn of oldConns) {
|
|
1002
|
+
const fresh = newConnsById.get(oldConn.id);
|
|
1003
|
+
if (!fresh) {
|
|
1004
|
+
await databaseService.closeConnection(oldConn.id);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
const oldKey = JSON.stringify({
|
|
1008
|
+
ssh: oldConn.ssh ?? null,
|
|
1009
|
+
host: oldConn.host,
|
|
1010
|
+
port: oldConn.port,
|
|
1011
|
+
});
|
|
1012
|
+
const newKey = JSON.stringify({
|
|
1013
|
+
ssh: fresh.ssh ?? null,
|
|
1014
|
+
host: fresh.host,
|
|
1015
|
+
port: fresh.port,
|
|
1016
|
+
});
|
|
1017
|
+
if (oldKey !== newKey) {
|
|
1018
|
+
await databaseService.closeConnection(oldConn.id);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
981
1022
|
// Check if this is a Docker-enabled building
|
|
982
1023
|
if (oldBuilding && newBuilding.docker?.enabled && oldBuilding.docker?.enabled) {
|
|
983
1024
|
const oldContainerName = dockerService.getContainerName(oldBuilding);
|
|
@@ -8,6 +8,7 @@ import oracledb from 'oracledb';
|
|
|
8
8
|
import Database from 'better-sqlite3';
|
|
9
9
|
import mssql from 'mssql';
|
|
10
10
|
import { loadQueryHistory, saveQueryHistory } from '../data/index.js';
|
|
11
|
+
import { getOrCreateTunnel, closeTunnel, closeAllTunnels, openTransientTunnel, closeTunnelHandle, } from './ssh-tunnel-service.js';
|
|
11
12
|
// Connection pool storage
|
|
12
13
|
const mysqlPools = new Map();
|
|
13
14
|
const pgPools = new Map();
|
|
@@ -23,6 +24,18 @@ const queryHistoryCache = new Map();
|
|
|
23
24
|
function getConnectionKey(connection, database) {
|
|
24
25
|
return `${connection.id}:${database || connection.database || 'default'}`;
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the effective network endpoint for a connection. When an SSH tunnel
|
|
29
|
+
* is enabled, this brings up (or reuses) the tunnel and returns the local
|
|
30
|
+
* forwarded host:port. Otherwise it returns the connection's own host:port.
|
|
31
|
+
*/
|
|
32
|
+
async function resolveEndpoint(connection) {
|
|
33
|
+
if (!connection.ssh?.enabled) {
|
|
34
|
+
return { host: connection.host, port: connection.port };
|
|
35
|
+
}
|
|
36
|
+
const tunnel = await getOrCreateTunnel(connection.id, connection.ssh, connection.host, connection.port);
|
|
37
|
+
return { host: tunnel.localHost, port: tunnel.localPort };
|
|
38
|
+
}
|
|
26
39
|
/**
|
|
27
40
|
* Get or create a MySQL connection pool
|
|
28
41
|
*/
|
|
@@ -31,9 +44,10 @@ async function getMySQLPool(connection, database) {
|
|
|
31
44
|
if (mysqlPools.has(key)) {
|
|
32
45
|
return mysqlPools.get(key);
|
|
33
46
|
}
|
|
47
|
+
const endpoint = await resolveEndpoint(connection);
|
|
34
48
|
const pool = mysql.createPool({
|
|
35
|
-
host:
|
|
36
|
-
port:
|
|
49
|
+
host: endpoint.host,
|
|
50
|
+
port: endpoint.port,
|
|
37
51
|
user: connection.username,
|
|
38
52
|
password: connection.password,
|
|
39
53
|
database: database || connection.database,
|
|
@@ -60,9 +74,10 @@ async function getPgPool(connection, database) {
|
|
|
60
74
|
if (pgPools.has(key)) {
|
|
61
75
|
return pgPools.get(key);
|
|
62
76
|
}
|
|
77
|
+
const endpoint = await resolveEndpoint(connection);
|
|
63
78
|
const pool = new pg.Pool({
|
|
64
|
-
host:
|
|
65
|
-
port:
|
|
79
|
+
host: endpoint.host,
|
|
80
|
+
port: endpoint.port,
|
|
66
81
|
user: connection.username,
|
|
67
82
|
password: connection.password,
|
|
68
83
|
database: database || connection.database || 'postgres',
|
|
@@ -96,7 +111,8 @@ async function getOraclePool(connection) {
|
|
|
96
111
|
// Format: host:port/serviceName
|
|
97
112
|
// The service name comes from connection.database (e.g., ORCLPDB1)
|
|
98
113
|
const serviceName = connection.database || 'ORCL';
|
|
99
|
-
const
|
|
114
|
+
const endpoint = await resolveEndpoint(connection);
|
|
115
|
+
const connectString = `${endpoint.host}:${endpoint.port}/${serviceName}`;
|
|
100
116
|
const pool = await oracledb.createPool({
|
|
101
117
|
user: connection.username,
|
|
102
118
|
password: connection.password,
|
|
@@ -138,9 +154,10 @@ async function getMSSQLPool(connection, database) {
|
|
|
138
154
|
// Pool disconnected, remove and recreate
|
|
139
155
|
mssqlPools.delete(key);
|
|
140
156
|
}
|
|
157
|
+
const endpoint = await resolveEndpoint(connection);
|
|
141
158
|
const config = {
|
|
142
|
-
server:
|
|
143
|
-
port:
|
|
159
|
+
server: endpoint.host,
|
|
160
|
+
port: endpoint.port,
|
|
144
161
|
user: connection.username,
|
|
145
162
|
password: connection.password,
|
|
146
163
|
database: database || connection.database,
|
|
@@ -159,9 +176,39 @@ async function getMSSQLPool(connection, database) {
|
|
|
159
176
|
return pool;
|
|
160
177
|
}
|
|
161
178
|
/**
|
|
162
|
-
* Test a database connection
|
|
179
|
+
* Test a database connection. If the connection is transient (no persisted
|
|
180
|
+
* building owns it yet) any SSH tunnel opened during the test is torn down
|
|
181
|
+
* before returning.
|
|
163
182
|
*/
|
|
164
|
-
export async function testConnection(connection) {
|
|
183
|
+
export async function testConnection(connection, options = {}) {
|
|
184
|
+
// When transient + SSH-enabled, run the entire test through a one-shot
|
|
185
|
+
// tunnel that's discarded immediately. Avoids leaving a tunnel open for an
|
|
186
|
+
// unsaved building.
|
|
187
|
+
if (options.transient && connection.ssh?.enabled) {
|
|
188
|
+
let handle;
|
|
189
|
+
try {
|
|
190
|
+
handle = await openTransientTunnel(connection.ssh, connection.host, connection.port);
|
|
191
|
+
const localizedConnection = {
|
|
192
|
+
...connection,
|
|
193
|
+
host: handle.localHost,
|
|
194
|
+
port: handle.localPort,
|
|
195
|
+
ssh: undefined,
|
|
196
|
+
};
|
|
197
|
+
return await testConnection(localizedConnection, { transient: false });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : 'Unknown SSH error';
|
|
201
|
+
return { success: false, error: `SSH tunnel failed: ${message}` };
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
if (handle) {
|
|
205
|
+
try {
|
|
206
|
+
await closeTunnelHandle(handle);
|
|
207
|
+
}
|
|
208
|
+
catch { /* ignore */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
165
212
|
try {
|
|
166
213
|
if (connection.engine === 'mysql') {
|
|
167
214
|
const pool = await getMySQLPool(connection);
|
|
@@ -1072,6 +1119,8 @@ export function clearHistory(buildingId) {
|
|
|
1072
1119
|
* Close all connection pools for a building/connection
|
|
1073
1120
|
*/
|
|
1074
1121
|
export async function closeConnection(connectionId) {
|
|
1122
|
+
// Tear down SSH tunnel first so subsequent pool ends don't try to reuse it
|
|
1123
|
+
await closeTunnel(connectionId);
|
|
1075
1124
|
// Close all pools that match this connection ID
|
|
1076
1125
|
for (const [key, pool] of mysqlPools.entries()) {
|
|
1077
1126
|
if (key.startsWith(connectionId + ':')) {
|
|
@@ -1128,6 +1177,9 @@ export async function closeAllConnections() {
|
|
|
1128
1177
|
await pool.close();
|
|
1129
1178
|
}
|
|
1130
1179
|
mssqlPools.clear();
|
|
1180
|
+
// Tear down all active SSH tunnels last so pool .end()/close() can
|
|
1181
|
+
// complete cleanly even if they need to talk to the server.
|
|
1182
|
+
await closeAllTunnels();
|
|
1131
1183
|
}
|
|
1132
1184
|
/**
|
|
1133
1185
|
* Get MSSQL type name from declaration
|
|
@@ -16,6 +16,7 @@ export * as subordinateContextService from './subordinate-context-service.js';
|
|
|
16
16
|
export * as workPlanService from './work-plan-service.js';
|
|
17
17
|
export * as secretsService from './secrets-service.js';
|
|
18
18
|
export * as databaseService from './database-service.js';
|
|
19
|
+
export * as sshTunnelService from './ssh-tunnel-service.js';
|
|
19
20
|
export * as eventRetentionService from './event-retention-service.js';
|
|
20
21
|
export * as triggerService from './trigger-service.js';
|
|
21
22
|
export * as workflowService from './workflow-service.js';
|