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.
- package/dist/assets/{BossLogsModal-S3Rke-8g.js → BossLogsModal-BK6N5fG2.js} +1 -1
- package/dist/assets/{BossSpawnModal-BjWGNCnz.js → BossSpawnModal-BTy-lus4.js} +1 -1
- package/dist/assets/{ControlsModal-6yfU0XjZ.js → ControlsModal-B4MhaF1V.js} +1 -1
- package/dist/assets/{DockerLogsModal-CYq0hNz6.js → DockerLogsModal-C33dAwy1.js} +1 -1
- package/dist/assets/{EmbeddedEditor-ZBdqRDqm.js → EmbeddedEditor-BfjjT-GF.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-BcV5jAse.js → GmailOAuthSetup-TQyjHs3_.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-DyUW_STE.js → GoogleOAuthSetup-DAIzYKy8.js} +1 -1
- package/dist/assets/{IframeModal-D9A3dUUc.js → IframeModal-g8tC4aah.js} +1 -1
- package/dist/assets/IntegrationsPanel-CuKr7702.js +2 -0
- package/dist/assets/{LogViewerModal-BWkbY7wa.js → LogViewerModal-DO45Kea0.js} +1 -1
- package/dist/assets/{MonitoringModal-AZzokZAZ.js → MonitoringModal-OIwmagj2.js} +1 -1
- package/dist/assets/{PM2LogsModal-q98eiBfq.js → PM2LogsModal-BRQzSiFN.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-CTxRP2qE.js → RestoreArchivedAreaModal-CBRN9Xpb.js} +1 -1
- package/dist/assets/{Scene2DCanvas-C11dztp1.js → Scene2DCanvas-4J4ZefT6.js} +1 -1
- package/dist/assets/{SceneManager-CsW9MYrD.js → SceneManager-DZsJcYvW.js} +1 -1
- package/dist/assets/{SkillsPanel-BeZr9w6E.js → SkillsPanel-DHk7h3Ja.js} +1 -1
- package/dist/assets/SlackMultiInstanceSetup-Dp1q2zM1.js +2 -0
- package/dist/assets/{SpawnModal-DY_KM6lX.js → SpawnModal-CfozYMNI.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-D6RvjGX9.js → SubordinateAssignmentModal-BBfbpVUr.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-BmqjXv9T.js → TriggerManagerPanel-DQw9nt1r.js} +2 -2
- package/dist/assets/{WorkflowEditorPanel-Rd5ZjJmt.js → WorkflowEditorPanel-BM2ec8CS.js} +1 -1
- package/dist/assets/{index-DSvJOrb7.js → index-BiAZinYH.js} +2 -2
- package/dist/assets/{index-BYVHgVEo.js → index-BqbR55dr.js} +1 -1
- package/dist/assets/{index-DRGyDtmm.js → index-CcSJA57k.js} +1 -1
- package/dist/assets/{index-BtJyOo4p.js → index-DNEUJDeO.js} +1 -1
- package/dist/assets/{index-BoORE9Q1.js → index-DY9w7IcH.js} +1 -1
- package/dist/assets/{index-DHHRkTG1.js → index-bcwTXJ6F.js} +1 -1
- package/dist/assets/index-fZfyvIUZ.js +2 -0
- package/dist/assets/{index-BxaEkSIx.js → index-jXkaBxIq.js} +3 -3
- package/dist/assets/index-xEvpFBA8.js +8 -0
- package/dist/assets/main-Bw5ZddEN.css +1 -0
- package/dist/assets/main-D-YFCprA.js +213 -0
- package/dist/assets/{web-D3zCwsS9.js → web-BrBkKQlr.js} +1 -1
- package/dist/assets/{web-DS0FHmg8.js → web-DCu3NTho.js} +1 -1
- package/dist/assets/{web-DEq3Te_H.js → web-DX588C-g.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/event-queries.js +2 -0
- package/dist/src/packages/server/data/index.js +56 -2
- package/dist/src/packages/server/data/migrations/006_slack_messages_integration_instance.sql +9 -0
- 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/slack/index.js +65 -19
- package/dist/src/packages/server/integrations/slack/slack-client.js +44 -602
- package/dist/src/packages/server/integrations/slack/slack-config.js +229 -29
- package/dist/src/packages/server/integrations/slack/slack-instance-manifest.js +150 -0
- package/dist/src/packages/server/integrations/slack/slack-instance.js +801 -0
- package/dist/src/packages/server/integrations/slack/slack-polling-client.js +522 -0
- package/dist/src/packages/server/integrations/slack/slack-routes.js +243 -24
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +53 -20
- package/dist/src/packages/server/integrations/slack/slack-watermark-store.js +124 -0
- 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 +127 -0
- package/dist/src/packages/server/routes/database.js +221 -0
- package/dist/src/packages/server/routes/files.js +219 -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/IntegrationsPanel-CHaNJBJW.js +0 -2
- 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
- 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 =
|
|
250
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
55
251
|
if (!resolution.ok) {
|
|
56
|
-
|
|
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 =
|
|
391
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
194
392
|
if (!resolution.ok) {
|
|
195
|
-
|
|
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 =
|
|
426
|
+
const resolution = findFileWithFallbacks(req.query.path, req.query.baseDir);
|
|
227
427
|
if (!resolution.ok) {
|
|
228
|
-
|
|
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:
|
|
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';
|