kiro-mobile-bridge 1.0.7 → 1.0.10

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.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * API Routes - REST endpoints for mobile client
3
+ */
4
+ import { Router } from 'express';
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { injectMessage } from '../services/message.js';
9
+ import { clickElement } from '../services/click.js';
10
+ import {
11
+ validatePathWithinRoot,
12
+ sanitizeClickInfo,
13
+ validateMessage,
14
+ sanitizeFilePath
15
+ } from '../utils/security.js';
16
+ import {
17
+ CODE_EXTENSIONS,
18
+ EXTENSION_TO_LANGUAGE,
19
+ getLanguageFromExtension,
20
+ isCodeFile,
21
+ MAX_FILE_SEARCH_DEPTH,
22
+ MAX_WORKSPACE_DEPTH
23
+ } from '../utils/constants.js';
24
+
25
+ /**
26
+ * Create API router
27
+ * @param {Map} cascades - Cascade connections map
28
+ * @param {object} mainWindowCDP - Main window CDP connection
29
+ * @returns {Router} - Express router
30
+ */
31
+ export function createApiRouter(cascades, mainWindowCDP) {
32
+ const router = Router();
33
+
34
+ // GET /snapshot/:id - Get HTML snapshot for a cascade
35
+ router.get('/snapshot/:id', (req, res) => {
36
+ const cascade = cascades.get(req.params.id);
37
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
38
+ if (!cascade.snapshot) return res.status(404).json({ error: 'No snapshot available' });
39
+ res.json(cascade.snapshot);
40
+ });
41
+
42
+ // GET /styles/:id - Get CSS for a cascade
43
+ router.get('/styles/:id', (req, res) => {
44
+ const cascade = cascades.get(req.params.id);
45
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
46
+ if (!cascade.css) return res.status(404).json({ error: 'No styles available' });
47
+ res.type('text/css').send(cascade.css);
48
+ });
49
+
50
+ // GET /editor/:id - Get editor snapshot
51
+ router.get('/editor/:id', (req, res) => {
52
+ const cascade = cascades.get(req.params.id);
53
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
54
+ if (!cascade.editor?.hasContent) return res.status(404).json({ error: 'No editor content available' });
55
+ res.json(cascade.editor);
56
+ });
57
+
58
+ // POST /send/:id - Send message to chat
59
+ router.post('/send/:id', async (req, res) => {
60
+ const cascade = cascades.get(req.params.id);
61
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
62
+
63
+ const { message } = req.body;
64
+
65
+ // Validate message input
66
+ const validation = validateMessage(message);
67
+ if (!validation.valid) {
68
+ return res.status(400).json({ error: validation.error });
69
+ }
70
+
71
+ if (!cascade.cdp) return res.status(503).json({ error: 'CDP connection not available' });
72
+
73
+ // Log message length only, not content (security)
74
+ console.log(`[Send] Message to ${req.params.id}: ${message.length} chars`);
75
+ const result = await injectMessage(cascade.cdp, message);
76
+
77
+ if (result.success) {
78
+ res.json({ success: true, method: result.method });
79
+ } else {
80
+ res.status(500).json({ success: false, error: result.error });
81
+ }
82
+ });
83
+
84
+ // POST /click/:id - Click UI element
85
+ router.post('/click/:id', async (req, res) => {
86
+ const cascade = cascades.get(req.params.id);
87
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
88
+ if (!cascade.cdp?.rootContextId) return res.status(503).json({ error: 'CDP not available' });
89
+
90
+ // Validate and sanitize click info
91
+ const { valid, sanitized, error } = sanitizeClickInfo(req.body);
92
+ if (!valid) {
93
+ return res.status(400).json({ error: error || 'Invalid click info' });
94
+ }
95
+
96
+ console.log(`[Click] ${sanitized.text?.substring(0, 30) || sanitized.ariaLabel || sanitized.tag || 'element'}`);
97
+
98
+ try {
99
+ const result = await clickElement(cascade.cdp, sanitized);
100
+ res.json(result);
101
+ } catch (err) {
102
+ console.error('[Click] Error:', err.message);
103
+ res.status(500).json({ success: false, error: err.message });
104
+ }
105
+ });
106
+
107
+ // GET /debug-model/:id - Debug model selector structure
108
+ router.get('/debug-model/:id', async (req, res) => {
109
+ const cascade = cascades.get(req.params.id);
110
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
111
+ if (!cascade.cdp?.rootContextId) return res.status(503).json({ error: 'CDP not available' });
112
+
113
+ const script = `(function() {
114
+ let targetDoc = document;
115
+ const activeFrame = document.getElementById('active-frame');
116
+ if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
117
+
118
+ const results = {
119
+ dropdownOpen: false,
120
+ dropdownItems: [],
121
+ dropdownContainer: null,
122
+ autopilotToggle: null
123
+ };
124
+
125
+ // Check if dropdown is open
126
+ const openDropdown = targetDoc.querySelector('.kiro-dropdown-menu, [class*="dropdown-menu"], [class*="dropdown-content"], [role="listbox"], [role="menu"]');
127
+ if (openDropdown && openDropdown.offsetParent !== null) {
128
+ results.dropdownOpen = true;
129
+ results.dropdownContainer = {
130
+ tag: openDropdown.tagName,
131
+ className: (openDropdown.className || '').substring(0, 100),
132
+ role: openDropdown.getAttribute('role'),
133
+ innerHTML: openDropdown.innerHTML.substring(0, 1000)
134
+ };
135
+
136
+ // Find all items in the dropdown
137
+ const items = openDropdown.querySelectorAll('.kiro-dropdown-item, [role="option"], [role="menuitem"], [class*="dropdown-item"], [class*="menu-item"], > div, > button');
138
+ items.forEach(item => {
139
+ results.dropdownItems.push({
140
+ tag: item.tagName,
141
+ className: (item.className || '').substring(0, 80),
142
+ text: (item.textContent || '').substring(0, 100),
143
+ role: item.getAttribute('role'),
144
+ dataValue: item.getAttribute('data-value'),
145
+ onclick: !!item.onclick,
146
+ cursor: window.getComputedStyle(item).cursor
147
+ });
148
+ });
149
+ }
150
+
151
+ // Find Autopilot toggle
152
+ const autopilotElements = targetDoc.querySelectorAll('[class*="toggle"], input[type="checkbox"]');
153
+ for (const el of autopilotElements) {
154
+ const parent = el.closest('.kiro-toggle-switch, [class*="toggle"]');
155
+ if (parent) {
156
+ const label = parent.querySelector('label');
157
+ const labelText = label ? label.textContent.toLowerCase() : '';
158
+ if (labelText.includes('autopilot') || labelText.includes('auto')) {
159
+ results.autopilotToggle = {
160
+ toggleElement: {
161
+ tag: el.tagName,
162
+ type: el.type,
163
+ className: (el.className || '').substring(0, 80),
164
+ checked: el.checked,
165
+ id: el.id
166
+ },
167
+ parentElement: {
168
+ tag: parent.tagName,
169
+ className: (parent.className || '').substring(0, 80),
170
+ onclick: !!parent.onclick
171
+ },
172
+ labelText: labelText
173
+ };
174
+ break;
175
+ }
176
+ }
177
+ }
178
+
179
+ return results;
180
+ })()`;
181
+
182
+ try {
183
+ const result = await cascade.cdp.call('Runtime.evaluate', {
184
+ expression: script,
185
+ contextId: cascade.cdp.rootContextId,
186
+ returnByValue: true
187
+ });
188
+
189
+ const data = result.result?.value;
190
+ console.log('[Debug] UI structure:', JSON.stringify(data, null, 2));
191
+ res.json(data);
192
+ } catch (err) {
193
+ res.status(500).json({ error: err.message });
194
+ }
195
+ });
196
+
197
+ // POST /readFile/:id - Read file from filesystem
198
+ router.post('/readFile/:id', async (req, res) => {
199
+ const cascade = cascades.get(req.params.id);
200
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
201
+
202
+ const { filePath } = req.body;
203
+ if (!filePath || typeof filePath !== 'string') {
204
+ return res.status(400).json({ error: 'filePath is required and must be a string' });
205
+ }
206
+
207
+ // Sanitize the file path
208
+ const sanitizedPath = sanitizeFilePath(filePath);
209
+ if (!sanitizedPath) {
210
+ return res.status(400).json({ error: 'Invalid file path' });
211
+ }
212
+
213
+ console.log(`[ReadFile] Request for: ${sanitizedPath}`);
214
+
215
+ try {
216
+ // Get workspace root
217
+ const workspaceRoot = await getWorkspaceRoot(mainWindowCDP) || process.cwd();
218
+
219
+ // SECURITY: Validate path is within workspace root
220
+ const pathValidation = validatePathWithinRoot(sanitizedPath, workspaceRoot);
221
+
222
+ let content = null;
223
+ let foundPath = null;
224
+
225
+ if (pathValidation.valid) {
226
+ // Try the validated path first
227
+ try {
228
+ content = await fs.readFile(pathValidation.resolvedPath, 'utf-8');
229
+ foundPath = pathValidation.resolvedPath;
230
+ } catch (e) {
231
+ // File doesn't exist at validated path, try searching
232
+ }
233
+ }
234
+
235
+ // If not found, search within workspace only
236
+ if (!content) {
237
+ const fileName = path.basename(sanitizedPath);
238
+ foundPath = await findFileRecursive(workspaceRoot, fileName, MAX_FILE_SEARCH_DEPTH);
239
+
240
+ if (foundPath) {
241
+ // Validate the found path is still within workspace
242
+ const foundValidation = validatePathWithinRoot(foundPath, workspaceRoot);
243
+ if (foundValidation.valid) {
244
+ content = await fs.readFile(foundPath, 'utf-8');
245
+ } else {
246
+ console.warn(`[ReadFile] Found file outside workspace: ${foundPath}`);
247
+ foundPath = null;
248
+ }
249
+ }
250
+ }
251
+
252
+ if (!content) {
253
+ return res.status(404).json({ error: 'File not found within workspace' });
254
+ }
255
+
256
+ const language = getLanguageFromExtension(sanitizedPath);
257
+
258
+ res.json({
259
+ content,
260
+ fileName: path.basename(sanitizedPath),
261
+ fullPath: foundPath,
262
+ language,
263
+ lineCount: content.split('\n').length,
264
+ hasContent: true
265
+ });
266
+ } catch (err) {
267
+ console.error('[ReadFile] Error:', err.message);
268
+ res.status(500).json({ error: 'Failed to read file' });
269
+ }
270
+ });
271
+
272
+ // GET /files/:id - List workspace files
273
+ router.get('/files/:id', async (req, res) => {
274
+ const cascade = cascades.get(req.params.id);
275
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
276
+
277
+ try {
278
+ const workspaceRoot = await getWorkspaceRoot(mainWindowCDP) || process.cwd();
279
+ const files = await collectWorkspaceFiles(workspaceRoot);
280
+
281
+ console.log(`[Files] Found ${files.length} files in ${workspaceRoot}`);
282
+ res.json({ files, workspaceRoot });
283
+ } catch (err) {
284
+ console.error('[Files] Error:', err.message);
285
+ res.status(500).json({ error: err.message });
286
+ }
287
+ });
288
+
289
+ // GET /tasks/:id - List task files from .kiro/specs
290
+ router.get('/tasks/:id', async (req, res) => {
291
+ const cascade = cascades.get(req.params.id);
292
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
293
+
294
+ try {
295
+ const workspaceRoot = await getWorkspaceRoot(mainWindowCDP) || process.cwd();
296
+ const kiroSpecsPath = path.join(workspaceRoot, '.kiro', 'specs');
297
+
298
+ try {
299
+ await fs.access(kiroSpecsPath);
300
+ } catch (e) {
301
+ return res.json({ tasks: [], workspaceRoot });
302
+ }
303
+
304
+ const tasks = [];
305
+ const specDirs = await fs.readdir(kiroSpecsPath, { withFileTypes: true });
306
+
307
+ for (const dir of specDirs) {
308
+ if (!dir.isDirectory()) continue;
309
+ const tasksFilePath = path.join(kiroSpecsPath, dir.name, 'tasks.md');
310
+ try {
311
+ const content = await fs.readFile(tasksFilePath, 'utf-8');
312
+ tasks.push({ name: dir.name, path: `.kiro/specs/${dir.name}/tasks.md`, content });
313
+ } catch (e) {
314
+ // Task file doesn't exist, skip
315
+ }
316
+ }
317
+
318
+ tasks.sort((a, b) => a.name.localeCompare(b.name));
319
+ console.log(`[Tasks] Found ${tasks.length} task files`);
320
+ res.json({ tasks, workspaceRoot });
321
+ } catch (err) {
322
+ console.error('[Tasks] Error:', err.message);
323
+ res.status(500).json({ error: err.message });
324
+ }
325
+ });
326
+
327
+ // POST /open-spec/:id - Open spec in Kiro
328
+ router.post('/open-spec/:id', async (req, res) => {
329
+ const cascade = cascades.get(req.params.id);
330
+ if (!cascade) return res.status(404).json({ error: 'Cascade not found' });
331
+
332
+ const { specName } = req.body;
333
+ if (!specName || typeof specName !== 'string') {
334
+ return res.status(400).json({ error: 'specName is required and must be a string' });
335
+ }
336
+
337
+ // Sanitize spec name (alphanumeric, hyphens, underscores only)
338
+ const sanitizedSpecName = specName.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 100);
339
+ if (!sanitizedSpecName) {
340
+ return res.status(400).json({ error: 'Invalid spec name' });
341
+ }
342
+
343
+ const cdp = cascade.cdp;
344
+ if (!cdp?.rootContextId) return res.status(503).json({ error: 'CDP not connected' });
345
+
346
+ console.log(`[OpenSpec] Opening ${sanitizedSpecName}`);
347
+
348
+ // Try to click on spec in sidebar
349
+ const script = `(function() {
350
+ let targetDoc = document;
351
+ const activeFrame = document.getElementById('active-frame');
352
+ if (activeFrame && activeFrame.contentDocument) targetDoc = activeFrame.contentDocument;
353
+
354
+ const specName = '${sanitizedSpecName}';
355
+ const allElements = targetDoc.querySelectorAll('*');
356
+
357
+ for (const el of allElements) {
358
+ const text = (el.textContent || '').trim();
359
+ if (text === specName || text.includes(specName)) {
360
+ if (el.offsetParent !== null && el.textContent.length < 100) {
361
+ el.click();
362
+ return { success: true, method: 'click-spec-name' };
363
+ }
364
+ }
365
+ }
366
+ return { success: false, error: 'Spec not found in UI' };
367
+ })()`;
368
+
369
+ try {
370
+ const result = await cdp.call('Runtime.evaluate', {
371
+ expression: script,
372
+ contextId: cdp.rootContextId,
373
+ returnByValue: true
374
+ });
375
+ res.json(result.result?.value || { success: false });
376
+ } catch (err) {
377
+ res.status(500).json({ success: false, error: err.message });
378
+ }
379
+ });
380
+
381
+ return router;
382
+ }
383
+
384
+ /**
385
+ * Get workspace root from VS Code window title
386
+ * Uses environment-based paths instead of hardcoded values
387
+ * @param {object} mainWindowCDP - Main window CDP connection
388
+ * @returns {Promise<string|null>}
389
+ */
390
+ async function getWorkspaceRoot(mainWindowCDP) {
391
+ if (!mainWindowCDP.connection?.rootContextId) return null;
392
+
393
+ try {
394
+ const result = await mainWindowCDP.connection.call('Runtime.evaluate', {
395
+ expression: 'document.title',
396
+ contextId: mainWindowCDP.connection.rootContextId,
397
+ returnByValue: true
398
+ });
399
+
400
+ const title = result.result?.value || '';
401
+ const parts = title.split(' - ');
402
+ if (parts.length >= 2) {
403
+ const folderName = parts[parts.length - 2].trim();
404
+ const homeDir = os.homedir();
405
+
406
+ // Build possible roots from environment, not hardcoded paths
407
+ const possibleRoots = [
408
+ process.cwd(),
409
+ path.join(homeDir, folderName),
410
+ path.join(homeDir, 'projects', folderName),
411
+ path.join(homeDir, 'dev', folderName),
412
+ path.join(homeDir, 'workspace', folderName),
413
+ path.join(homeDir, 'code', folderName)
414
+ ];
415
+
416
+ // Add Windows-specific paths if on Windows
417
+ if (process.platform === 'win32') {
418
+ const drives = ['C:', 'D:', 'E:'];
419
+ for (const drive of drives) {
420
+ possibleRoots.push(
421
+ path.join(drive, 'dev', folderName),
422
+ path.join(drive, 'projects', folderName),
423
+ path.join(drive, 'workspace', folderName)
424
+ );
425
+ }
426
+ }
427
+
428
+ for (const root of possibleRoots) {
429
+ try {
430
+ const stat = await fs.stat(root);
431
+ if (stat.isDirectory()) {
432
+ const hasKiro = await fs.access(path.join(root, '.kiro')).then(() => true).catch(() => false);
433
+ const hasPackage = await fs.access(path.join(root, 'package.json')).then(() => true).catch(() => false);
434
+ const hasGit = await fs.access(path.join(root, '.git')).then(() => true).catch(() => false);
435
+ if (hasKiro || hasPackage || hasGit) return root;
436
+ }
437
+ } catch (e) {
438
+ // Path doesn't exist, continue
439
+ }
440
+ }
441
+ }
442
+ } catch (e) {
443
+ console.error('[getWorkspaceRoot] Error:', e.message);
444
+ }
445
+ return null;
446
+ }
447
+
448
+ /**
449
+ * Find file recursively within a directory
450
+ * @param {string} dir - Directory to search
451
+ * @param {string} fileName - File name to find
452
+ * @param {number} maxDepth - Maximum search depth
453
+ * @param {number} currentDepth - Current depth (internal)
454
+ * @returns {Promise<string|null>}
455
+ */
456
+ async function findFileRecursive(dir, fileName, maxDepth = MAX_FILE_SEARCH_DEPTH, currentDepth = 0) {
457
+ if (currentDepth > maxDepth) return null;
458
+
459
+ try {
460
+ const entries = await fs.readdir(dir, { withFileTypes: true });
461
+
462
+ // Check files first
463
+ for (const entry of entries) {
464
+ if (entry.isFile() && entry.name === fileName) {
465
+ return path.join(dir, entry.name);
466
+ }
467
+ }
468
+
469
+ // Then recurse into directories
470
+ for (const entry of entries) {
471
+ if (entry.isDirectory() &&
472
+ (!entry.name.startsWith('.') || entry.name === '.kiro') &&
473
+ entry.name !== 'node_modules' &&
474
+ entry.name !== 'dist' &&
475
+ entry.name !== 'build' &&
476
+ entry.name !== '.git') {
477
+ const found = await findFileRecursive(
478
+ path.join(dir, entry.name),
479
+ fileName,
480
+ maxDepth,
481
+ currentDepth + 1
482
+ );
483
+ if (found) return found;
484
+ }
485
+ }
486
+ } catch (e) {
487
+ // Directory not accessible, skip
488
+ }
489
+ return null;
490
+ }
491
+
492
+ /**
493
+ * Collect workspace files for file tree
494
+ * @param {string} workspaceRoot - Workspace root directory
495
+ * @returns {Promise<Array<{name: string, path: string, language: string}>>}
496
+ */
497
+ async function collectWorkspaceFiles(workspaceRoot) {
498
+ const files = [];
499
+
500
+ async function collect(dir, relativePath = '', depth = 0) {
501
+ if (depth > MAX_WORKSPACE_DEPTH) return;
502
+
503
+ try {
504
+ const entries = await fs.readdir(dir, { withFileTypes: true });
505
+
506
+ for (const entry of entries) {
507
+ // Skip hidden files (except .kiro and .github), node_modules, and build directories
508
+ if ((entry.name.startsWith('.') && entry.name !== '.kiro' && entry.name !== '.github') ||
509
+ entry.name === 'node_modules' ||
510
+ entry.name === 'dist' ||
511
+ entry.name === 'build' ||
512
+ entry.name === '__pycache__') {
513
+ continue;
514
+ }
515
+
516
+ const entryPath = path.join(dir, entry.name);
517
+ const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
518
+
519
+ if (entry.isFile()) {
520
+ if (isCodeFile(entry.name)) {
521
+ files.push({
522
+ name: entry.name,
523
+ path: entryRelative,
524
+ language: getLanguageFromExtension(entry.name)
525
+ });
526
+ }
527
+ } else if (entry.isDirectory()) {
528
+ await collect(entryPath, entryRelative, depth + 1);
529
+ }
530
+ }
531
+ } catch (e) {
532
+ // Directory not accessible, skip
533
+ }
534
+ }
535
+
536
+ await collect(workspaceRoot);
537
+ files.sort((a, b) => a.path.localeCompare(b.path));
538
+ return files;
539
+ }