tide-commander 1.87.0 → 1.89.0

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 (71) hide show
  1. package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
  3. package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
  8. package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
  9. package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
  10. package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
  11. package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
  15. package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
  16. package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
  17. package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
  18. package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
  21. package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
  22. package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
  23. package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
  24. package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
  25. package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
  26. package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
  27. package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
  28. package/dist/assets/index-fZfyvIUZ.js +2 -0
  29. package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
  30. package/dist/assets/index-xEvpFBA8.js +8 -0
  31. package/dist/assets/main-Bw5ZddEN.css +1 -0
  32. package/dist/assets/main-D-YFCprA.js +213 -0
  33. package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
  34. package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
  35. package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/src/packages/server/data/builtin-skills/explore-database.js +175 -0
  38. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  39. package/dist/src/packages/server/data/event-queries.js +2 -0
  40. package/dist/src/packages/server/data/index.js +56 -2
  41. package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
  42. package/dist/src/packages/server/index.js +2 -1
  43. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +9 -1
  44. package/dist/src/packages/server/integrations/slack/index.js +65 -19
  45. package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
  46. package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
  47. package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
  48. package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
  49. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
  50. package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
  51. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
  52. package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
  53. package/dist/src/packages/server/integrations/whatsapp/index.js +5 -4
  54. package/dist/src/packages/server/integrations/whatsapp/whatsapp-client.js +10 -0
  55. package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +68 -0
  56. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +127 -0
  57. package/dist/src/packages/server/routes/database.js +221 -0
  58. package/dist/src/packages/server/routes/files.js +219 -18
  59. package/dist/src/packages/server/routes/index.js +2 -0
  60. package/dist/src/packages/server/services/building-service.js +41 -0
  61. package/dist/src/packages/server/services/database-service.js +61 -9
  62. package/dist/src/packages/server/services/index.js +1 -0
  63. package/dist/src/packages/server/services/ssh-tunnel-service.js +255 -0
  64. package/dist/src/packages/server/websocket/handler.js +2 -1
  65. package/dist/src/packages/server/websocket/handlers/database-handler.js +35 -0
  66. package/package.json +3 -1
  67. package/dist/assets/IntegrationsPanel-CHaNJBJW.js +0 -2
  68. package/dist/assets/index-BOr_tbLK.js +0 -2
  69. package/dist/assets/index-Co7njQ0Q.js +0 -8
  70. package/dist/assets/main-BrZe9Zbd.js +0 -201
  71. package/dist/assets/main-kpU9m5LW.css +0 -1
@@ -41,6 +41,202 @@ 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, strategy) {
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, { path: found, strategy });
55
+ }
56
+ // Recursive-walk cache for suffix-match: rooted at an absolute directory, value
57
+ // is the flat list of file absolute paths under that root. TTL keeps it warm
58
+ // across rapid clicks but lets edits propagate. Keys evict on TTL only — small
59
+ // LRU cap as a memory ceiling.
60
+ const SUFFIX_WALK_TTL_MS = 30_000;
61
+ const SUFFIX_WALK_MAX_ROOTS = 16;
62
+ const SUFFIX_WALK_MAX_DEPTH = 6;
63
+ const SUFFIX_WALK_IGNORE = new Set([
64
+ 'node_modules', '.git', 'dist', 'build', 'vendor', '.next', 'target',
65
+ '.cache', '.turbo', '.venv', '__pycache__',
66
+ ]);
67
+ const suffixWalkCache = new Map();
68
+ export function _resetSuffixWalkCacheForTests() {
69
+ suffixWalkCache.clear();
70
+ }
71
+ function listFilesShallow(root, depth) {
72
+ if (depth > SUFFIX_WALK_MAX_DEPTH)
73
+ return [];
74
+ let entries;
75
+ try {
76
+ entries = fs.readdirSync(root, { withFileTypes: true });
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ const out = [];
82
+ for (const e of entries) {
83
+ if (e.name.startsWith('.') && SUFFIX_WALK_IGNORE.has(e.name))
84
+ continue;
85
+ if (SUFFIX_WALK_IGNORE.has(e.name))
86
+ continue;
87
+ const full = path.join(root, e.name);
88
+ if (e.isDirectory()) {
89
+ out.push(...listFilesShallow(full, depth + 1));
90
+ }
91
+ else if (e.isFile()) {
92
+ out.push(full);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ function getWalkedFiles(root, now = Date.now()) {
98
+ const cached = suffixWalkCache.get(root);
99
+ if (cached && cached.expiresAt > now)
100
+ return cached.files;
101
+ const files = listFilesShallow(root, 0);
102
+ if (suffixWalkCache.size >= SUFFIX_WALK_MAX_ROOTS) {
103
+ const firstKey = suffixWalkCache.keys().next().value;
104
+ if (firstKey !== undefined)
105
+ suffixWalkCache.delete(firstKey);
106
+ }
107
+ suffixWalkCache.set(root, { files, expiresAt: now + SUFFIX_WALK_TTL_MS });
108
+ return files;
109
+ }
110
+ function findBySuffixMatch(rawPath, walkRoot) {
111
+ const segments = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
112
+ if (segments.length === 0)
113
+ return null;
114
+ const files = getWalkedFiles(walkRoot);
115
+ if (files.length === 0)
116
+ return null;
117
+ // Try the longest possible suffix first (most specific), shrinking down to
118
+ // basename. The first suffix length with a UNIQUE hit wins — ambiguous suffix
119
+ // means we'd guess wrong, so fall through.
120
+ for (let take = Math.min(segments.length, 4); take >= 1; take--) {
121
+ const suffix = path.sep + segments.slice(-take).join(path.sep);
122
+ const matches = files.filter(f => f.endsWith(suffix));
123
+ if (matches.length === 1)
124
+ return matches[0];
125
+ if (matches.length > 1)
126
+ continue;
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Resolve a requested file path to an existing file on disk, with fallbacks.
132
+ * Tries (in order):
133
+ * 1. exact — resolved by resolveAndValidateFilePath() (absolute or baseDir+path)
134
+ * 2. cached — previously-resolved entry for the same requested key
135
+ * 3. parent-walk — tail slices anchored at baseDir AND each ancestor up to /
136
+ * 4. git-root — tail slices anchored at the git toplevel from baseDir
137
+ * 5. suffix-match — last-resort: unique trailing-segment match within a
138
+ * depth-limited walk of baseDir, ignoring vendored dirs
139
+ * On miss, returns the absolute path requested AND the list of paths tried so
140
+ * the caller can surface a clear, debuggable error.
141
+ */
142
+ export function findFileWithFallbacks(rawPath, baseDir) {
143
+ if (!rawPath) {
144
+ return { ok: false, status: 400, error: 'Missing path parameter' };
145
+ }
146
+ const resolution = resolveAndValidateFilePath(rawPath, baseDir);
147
+ if (!resolution.ok) {
148
+ return resolution;
149
+ }
150
+ const tried = [];
151
+ const seen = new Set();
152
+ const tryCandidate = (p) => {
153
+ if (seen.has(p))
154
+ return null;
155
+ seen.add(p);
156
+ tried.push(p);
157
+ try {
158
+ if (fs.existsSync(p) && !fs.statSync(p).isDirectory())
159
+ return p;
160
+ }
161
+ catch { /* permission denied, broken symlink — keep trying */ }
162
+ return null;
163
+ };
164
+ const direct = tryCandidate(resolution.path);
165
+ if (direct)
166
+ return { ok: true, path: direct, strategy: 'exact' };
167
+ const cached = resolvedPathCache.get(rawPath);
168
+ if (cached) {
169
+ const hit = tryCandidate(cached.path);
170
+ if (hit)
171
+ return { ok: true, path: hit, strategy: 'cached' };
172
+ resolvedPathCache.delete(rawPath);
173
+ }
174
+ const absBase = baseDir && path.isAbsolute(baseDir) ? baseDir : null;
175
+ if (absBase) {
176
+ // Strip leading slashes so absolute and relative requested paths share one
177
+ // segment list. Walking the tail anchors `<a>/<b>/<c>` not just at baseDir
178
+ // but also at `<b>/<c>` and `<c>` against each ancestor — the file is
179
+ // found regardless of which slice of the path moved.
180
+ const tailSegments = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
181
+ let cur = absBase;
182
+ // Bound the climb so a deep baseDir doesn't search the whole filesystem.
183
+ for (let depth = 0; depth < 12; depth++) {
184
+ for (let i = 0; i < tailSegments.length; i++) {
185
+ const candidate = path.join(cur, ...tailSegments.slice(i));
186
+ const hit = tryCandidate(candidate);
187
+ if (hit) {
188
+ rememberResolution(rawPath, hit, 'parent-walk');
189
+ return { ok: true, path: hit, strategy: 'parent-walk' };
190
+ }
191
+ }
192
+ const parent = path.dirname(cur);
193
+ if (parent === cur)
194
+ break;
195
+ cur = parent;
196
+ }
197
+ try {
198
+ if (fs.existsSync(absBase)) {
199
+ const gitTop = execSync('git rev-parse --show-toplevel', {
200
+ cwd: absBase,
201
+ encoding: 'utf-8',
202
+ stdio: ['pipe', 'pipe', 'pipe'],
203
+ }).trim();
204
+ if (gitTop) {
205
+ for (let i = 0; i < tailSegments.length; i++) {
206
+ const candidate = path.join(gitTop, ...tailSegments.slice(i));
207
+ const hit = tryCandidate(candidate);
208
+ if (hit) {
209
+ rememberResolution(rawPath, hit, 'git-root');
210
+ return { ok: true, path: hit, strategy: 'git-root' };
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ catch { /* baseDir is not in a git repo — skip */ }
217
+ // Last-resort suffix match: cheap depth-limited recursive walk, cached for
218
+ // 30s. Helps when the requested path's segments don't anchor anywhere via
219
+ // parent-walk (e.g. file moved between dirs while UI still cites old path).
220
+ try {
221
+ if (fs.existsSync(absBase)) {
222
+ const suffixHit = findBySuffixMatch(rawPath, absBase);
223
+ if (suffixHit) {
224
+ tried.push(`<suffix-match in ${absBase}>`);
225
+ rememberResolution(rawPath, suffixHit, 'suffix-match');
226
+ return { ok: true, path: suffixHit, strategy: 'suffix-match' };
227
+ }
228
+ }
229
+ }
230
+ catch { /* walk failed entirely — fall through to 404 */ }
231
+ }
232
+ return {
233
+ ok: false,
234
+ status: 404,
235
+ error: 'File not found',
236
+ requested: resolution.path,
237
+ tried,
238
+ };
239
+ }
44
240
  // Prevent browser from caching git-related GET responses (status, diff, branch, etc.)
45
241
  // Without this, browsers may serve stale cached data — e.g. deleted files still appearing.
46
242
  router.use('/git-*path', (_req, res, next) => {
@@ -51,16 +247,17 @@ router.use('/git-*path', (_req, res, next) => {
51
247
  // GET /api/files/read - Read file contents
52
248
  router.get('/read', async (req, res) => {
53
249
  try {
54
- const resolution = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
250
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
55
251
  if (!resolution.ok) {
56
- res.status(resolution.status).json({ error: resolution.error });
252
+ const body = { error: resolution.error };
253
+ if (resolution.requested)
254
+ body.path = resolution.requested;
255
+ if (resolution.tried)
256
+ body.triedRoots = resolution.tried;
257
+ res.status(resolution.status).json(body);
57
258
  return;
58
259
  }
59
260
  const filePath = resolution.path;
60
- if (!fs.existsSync(filePath)) {
61
- res.status(404).json({ error: 'File not found', path: filePath });
62
- return;
63
- }
64
261
  const stats = fs.statSync(filePath);
65
262
  if (stats.isDirectory()) {
66
263
  res.status(400).json({ error: 'Path is a directory', path: filePath });
@@ -81,6 +278,7 @@ router.get('/read', async (req, res) => {
81
278
  content,
82
279
  size: stats.size,
83
280
  modified: stats.mtime,
281
+ strategy: resolution.strategy,
84
282
  });
85
283
  }
86
284
  catch (err) {
@@ -190,16 +388,17 @@ router.get('/exists', async (req, res) => {
190
388
  // GET /api/files/info - Get file info without content
191
389
  router.get('/info', async (req, res) => {
192
390
  try {
193
- const resolution = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
391
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
194
392
  if (!resolution.ok) {
195
- res.status(resolution.status).json({ error: resolution.error });
393
+ const body = { error: resolution.error };
394
+ if (resolution.requested)
395
+ body.path = resolution.requested;
396
+ if (resolution.tried)
397
+ body.triedRoots = resolution.tried;
398
+ res.status(resolution.status).json(body);
196
399
  return;
197
400
  }
198
401
  const filePath = resolution.path;
199
- if (!fs.existsSync(filePath)) {
200
- res.status(404).json({ error: 'File not found', path: filePath });
201
- return;
202
- }
203
402
  const stats = fs.statSync(filePath);
204
403
  if (stats.isDirectory()) {
205
404
  res.status(400).json({ error: 'Path is a directory', path: filePath });
@@ -213,6 +412,7 @@ router.get('/info', async (req, res) => {
213
412
  extension,
214
413
  size: stats.size,
215
414
  modified: stats.mtime,
415
+ strategy: resolution.strategy,
216
416
  });
217
417
  }
218
418
  catch (err) {
@@ -223,17 +423,18 @@ router.get('/info', async (req, res) => {
223
423
  // GET /api/files/binary - Read binary file (for images, PDFs, downloads)
224
424
  router.get('/binary', async (req, res) => {
225
425
  try {
226
- const resolution = resolveAndValidateFilePath(req.query.path, req.query.baseDir);
426
+ const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
227
427
  if (!resolution.ok) {
228
- res.status(resolution.status).json({ error: resolution.error });
428
+ const body = { error: resolution.error };
429
+ if (resolution.requested)
430
+ body.path = resolution.requested;
431
+ if (resolution.tried)
432
+ body.triedRoots = resolution.tried;
433
+ res.status(resolution.status).json(body);
229
434
  return;
230
435
  }
231
436
  const filePath = resolution.path;
232
437
  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
438
  const stats = fs.statSync(filePath);
238
439
  if (stats.isDirectory()) {
239
440
  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';