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.
Files changed (57) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BXQh_FI_.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BevC86-H.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-OTv1pMsh.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-oLpkSRWn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-Ch6-Ql2A.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-wOwZdO76.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-ChVRBoAO.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-BAQxs9VC.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-CHaNJBJW.js → IntegrationsPanel-YFcOTDdT.js} +2 -2
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-CSAEl5nL.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-Bgu6BN93.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-D0iQKsKn.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-SDm4CHQ_.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-kZMJfRRF.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-CcMFDU1A.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DKT8TwSH.js} +1 -1
  17. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-DKfic48e.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BfSS7J4H.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-C8zZf9rr.js} +2 -2
  20. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BsEbQzL1.js} +1 -1
  21. package/dist/assets/{index-DHHRkTG1.js → index-BW6pasQS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-Bm2GprIp.js} +2 -2
  23. package/dist/assets/{index-BoORE9Q1.js → index-Cn6pTvu9.js} +1 -1
  24. package/dist/assets/{index-BYVHgVEo.js → index-Cq-zN7dW.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-CrveGM3a.js} +1 -1
  26. package/dist/assets/index-DBbK5bs6.js +8 -0
  27. package/dist/assets/index-DeGYOmjB.js +2 -0
  28. package/dist/assets/{index-DRGyDtmm.js → index-JfilXCJq.js} +1 -1
  29. package/dist/assets/{index-BxaEkSIx.js → index-NPF6_aDv.js} +3 -3
  30. package/dist/assets/{main-kpU9m5LW.css → main-Cm8LaUxX.css} +1 -1
  31. package/dist/assets/main-CwfJmt2N.js +209 -0
  32. package/dist/assets/{web-DS0FHmg8.js → web-CN0zYUhV.js} +1 -1
  33. package/dist/assets/{web-D3zCwsS9.js → web-Dik5OZ1r.js} +1 -1
  34. package/dist/assets/{web-DEq3Te_H.js → web-JhU3bMzp.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  37. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  38. package/dist/src/packages/server/data/index.js +56 -2
  39. package/dist/src/packages/server/index.js +2 -1
  40. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  41. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  42. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  43. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  44. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +113 -0
  45. package/dist/src/packages/server/routes/database.js +221 -0
  46. package/dist/src/packages/server/routes/files.js +128 -18
  47. package/dist/src/packages/server/routes/index.js +2 -0
  48. package/dist/src/packages/server/services/building-service.js +41 -0
  49. package/dist/src/packages/server/services/database-service.js +61 -9
  50. package/dist/src/packages/server/services/index.js +1 -0
  51. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  52. package/dist/src/packages/server/websocket/handler.js +2 -1
  53. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  54. package/package.json +3 -1
  55. package/dist/assets/index-BOr_tbLK.js +0 -2
  56. package/dist/assets/index-Co7njQ0Q.js +0 -8
  57. 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 = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
161
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
55
162
  if (!resolution.ok) {
56
- res.status(resolution.status).json({ error: resolution.error });
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 = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
301
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
194
302
  if (!resolution.ok) {
195
- res.status(resolution.status).json({ error: resolution.error });
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 = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
335
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
227
336
  if (!resolution.ok) {
228
- res.status(resolution.status).json({ error: resolution.error });
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: connection.host,
36
- port: connection.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: connection.host,
65
- port: connection.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 connectString = `${connection.host}:${connection.port}/${serviceName}`;
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: connection.host,
143
- port: connection.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';