chrometools-mcp 1.9.1 → 2.3.2
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/CHANGELOG.md +305 -0
- package/README.md +279 -53
- package/browser/browser-manager.js +206 -0
- package/browser/page-manager.js +298 -0
- package/index.js +625 -1875
- package/package.json +1 -1
- package/recorder/page-object-generator.js +720 -0
- package/recorder/recorder-script.js +63 -9
- package/recorder/scenario-executor.js +47 -27
- package/recorder/scenario-storage.js +251 -29
- package/server/tool-definitions.js +655 -0
- package/server/tool-schemas.js +295 -0
- package/utils/code-generators/code-generator-base.js +61 -0
- package/utils/code-generators/file-appender.js +202 -0
- package/utils/code-generators/playwright-python.js +84 -0
- package/utils/code-generators/playwright-typescript.js +95 -0
- package/utils/code-generators/selenium-java.js +123 -0
- package/utils/code-generators/selenium-python.js +82 -0
- package/utils/css-utils.js +151 -0
- package/utils/image-processing.js +236 -0
- package/utils/platform-utils.js +62 -0
- package/utils/url-to-project.js +141 -0
- package/utils/project-detector.js +0 -87
|
@@ -27,6 +27,56 @@ export function generateRecorderScript() {
|
|
|
27
27
|
(function() {
|
|
28
28
|
'use strict';
|
|
29
29
|
|
|
30
|
+
// ==========================
|
|
31
|
+
// URL TO PROJECT UTILITIES
|
|
32
|
+
// ==========================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract project ID from URL (browser version)
|
|
36
|
+
* @param {string} url - Full URL
|
|
37
|
+
* @returns {string} - Project ID
|
|
38
|
+
*/
|
|
39
|
+
function urlToProjectId(url) {
|
|
40
|
+
try {
|
|
41
|
+
if (url.startsWith('file://')) {
|
|
42
|
+
return 'local';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const urlObj = new URL(url);
|
|
46
|
+
let hostname = urlObj.hostname.toLowerCase();
|
|
47
|
+
const port = urlObj.port;
|
|
48
|
+
|
|
49
|
+
hostname = hostname.replace(/^www\\./, '');
|
|
50
|
+
const parts = hostname.split('.');
|
|
51
|
+
|
|
52
|
+
if (parts.length === 1) {
|
|
53
|
+
const projectId = sanitizeProjectId(parts[0]);
|
|
54
|
+
return port ? \`\${projectId}-\${port}\` : projectId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mainDomain = parts[parts.length - 2];
|
|
58
|
+
const projectId = sanitizeProjectId(mainDomain);
|
|
59
|
+
return port ? \`\${projectId}-\${port}\` : projectId;
|
|
60
|
+
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('[url-to-project] Invalid URL:', url, error);
|
|
63
|
+
return 'unknown';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sanitize project ID (browser version)
|
|
69
|
+
* @param {string} id - Raw project ID
|
|
70
|
+
* @returns {string} - Sanitized ID
|
|
71
|
+
*/
|
|
72
|
+
function sanitizeProjectId(id) {
|
|
73
|
+
return id
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
76
|
+
.replace(/-+/g, '-')
|
|
77
|
+
.replace(/^-|-$/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
30
80
|
// ==========================
|
|
31
81
|
// RECORDER STATE
|
|
32
82
|
// ==========================
|
|
@@ -1393,7 +1443,10 @@ export function generateRecorderScript() {
|
|
|
1393
1443
|
// Call MCP server via exposed function
|
|
1394
1444
|
if (window.saveScenarioToMCP) {
|
|
1395
1445
|
try {
|
|
1396
|
-
|
|
1446
|
+
// Extract projectId from URL
|
|
1447
|
+
const urlProjectId = urlToProjectId(state.startUrl || window.location.href);
|
|
1448
|
+
|
|
1449
|
+
const result = await window.saveScenarioToMCP(scenario, urlProjectId);
|
|
1397
1450
|
if (result.success) {
|
|
1398
1451
|
// Set clearing flag to prevent any saves during cleanup
|
|
1399
1452
|
isClearing = true;
|
|
@@ -1651,9 +1704,10 @@ export function generateRecorderScript() {
|
|
|
1651
1704
|
|
|
1652
1705
|
/**
|
|
1653
1706
|
* Inject recorder into page
|
|
1707
|
+
* Project ID will be automatically determined from URL in browser context
|
|
1654
1708
|
* @param {Object} page - Puppeteer page instance
|
|
1655
1709
|
*/
|
|
1656
|
-
export async function injectRecorder(page
|
|
1710
|
+
export async function injectRecorder(page) {
|
|
1657
1711
|
try {
|
|
1658
1712
|
// Check if recorder is already injected
|
|
1659
1713
|
const alreadyInjected = await page.evaluate(() => {
|
|
@@ -1681,20 +1735,20 @@ export async function injectRecorder(page, baseDir) {
|
|
|
1681
1735
|
|
|
1682
1736
|
// Only expose functions if they don't exist yet
|
|
1683
1737
|
if (!functionExists) {
|
|
1684
|
-
|
|
1738
|
+
// saveScenarioToMCP now receives urlProjectId from browser
|
|
1739
|
+
await page.exposeFunction('saveScenarioToMCP', async (scenarioData, urlProjectId) => {
|
|
1685
1740
|
const { saveScenario } = await import('./scenario-storage.js');
|
|
1686
|
-
return await saveScenario(scenarioData,
|
|
1741
|
+
return await saveScenario(scenarioData, urlProjectId);
|
|
1687
1742
|
});
|
|
1688
1743
|
|
|
1744
|
+
// listScenariosFromMCP returns ALL scenarios (no filtering by project)
|
|
1689
1745
|
await page.exposeFunction('listScenariosFromMCP', async () => {
|
|
1690
|
-
const {
|
|
1691
|
-
const path = await import('path');
|
|
1746
|
+
const { listScenarios } = await import('./scenario-storage.js');
|
|
1692
1747
|
try {
|
|
1693
|
-
const
|
|
1694
|
-
const index = await loadIndex(scenariosDir);
|
|
1748
|
+
const scenarios = await listScenarios(null, true); // null projectId, allProjects=true
|
|
1695
1749
|
return {
|
|
1696
1750
|
success: true,
|
|
1697
|
-
scenarios:
|
|
1751
|
+
scenarios: scenarios
|
|
1698
1752
|
};
|
|
1699
1753
|
} catch (error) {
|
|
1700
1754
|
return {
|
|
@@ -21,16 +21,16 @@ const debugLog = DEBUG_MODE ? console.error : () => {};
|
|
|
21
21
|
* @param {string} scenarioName - Scenario to execute
|
|
22
22
|
* @param {Object} page - Puppeteer page instance
|
|
23
23
|
* @param {Object} params - Parameters for scenario
|
|
24
|
-
* @param {Object} options - Execution options { executeDependencies, skipConditions, maxRetries, timeout
|
|
24
|
+
* @param {Object} options - Execution options { executeDependencies, skipConditions, maxRetries, timeout }
|
|
25
25
|
* @returns {Object} - Execution result
|
|
26
26
|
*/
|
|
27
27
|
export async function executeScenario(scenarioName, page, params = {}, options = {}) {
|
|
28
28
|
const {
|
|
29
|
-
executeDependencies = true, //
|
|
29
|
+
executeDependencies = true, // Execute dependencies by default
|
|
30
30
|
skipConditions = false,
|
|
31
31
|
maxRetries = 3,
|
|
32
32
|
timeout = 30000,
|
|
33
|
-
|
|
33
|
+
projectId = null // Optional projectId for disambiguation
|
|
34
34
|
} = options;
|
|
35
35
|
|
|
36
36
|
const result = {
|
|
@@ -45,30 +45,50 @@ export async function executeScenario(scenarioName, page, params = {}, options =
|
|
|
45
45
|
const startTime = Date.now();
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
-
// Load scenario
|
|
49
|
-
const
|
|
50
|
-
const scenariosDir = path.join(baseDir, 'scenarios');
|
|
51
|
-
const scenarioIndex = await loadIndex(scenariosDir);
|
|
48
|
+
// Load scenario to get its metadata (needed for dependency resolution)
|
|
49
|
+
const initialScenario = await loadScenario(scenarioName, false, projectId);
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
if (!initialScenario) {
|
|
52
|
+
result.errors.push(`Scenario "${scenarioName}" not found`);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
if (
|
|
57
|
-
|
|
56
|
+
// Check for name collision
|
|
57
|
+
if (initialScenario.collision) {
|
|
58
|
+
result.errors.push(initialScenario.message);
|
|
59
|
+
result.availableProjectIds = initialScenario.availableProjectIds;
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
result.errors.push(...resolution.errors);
|
|
61
|
-
return result;
|
|
62
|
-
}
|
|
63
|
+
let chain = [{ name: scenarioName, projectId }]; // Default: execute only the requested scenario
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
// Resolve and execute dependencies if enabled
|
|
66
|
+
if (executeDependencies && initialScenario.metadata?.dependencies) {
|
|
67
|
+
// Build a simplified index from metadata for dependency resolution
|
|
68
|
+
// In the new system, we need to resolve cross-project dependencies
|
|
69
|
+
// Dependencies inherit parent's projectId unless they specify their own
|
|
70
|
+
chain = [
|
|
71
|
+
...initialScenario.metadata.dependencies.map(dep => ({
|
|
72
|
+
name: dep.scenario,
|
|
73
|
+
projectId: dep.projectId || projectId // Use explicit projectId or inherit from parent
|
|
74
|
+
})),
|
|
75
|
+
{ name: scenarioName, projectId }
|
|
76
|
+
];
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
// Execute chain in order
|
|
68
|
-
for (const
|
|
69
|
-
const scenario = await loadScenario(name,
|
|
80
|
+
for (const item of chain) {
|
|
81
|
+
const scenario = await loadScenario(item.name, true, item.projectId); // Load with secrets, using item's projectId
|
|
82
|
+
|
|
70
83
|
if (!scenario) {
|
|
71
|
-
result.errors.push(`Scenario "${name}" not found`);
|
|
84
|
+
result.errors.push(`Scenario "${item.name}" not found`);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for name collision in dependencies
|
|
89
|
+
if (scenario.collision) {
|
|
90
|
+
result.errors.push(`Dependency "${item.name}": ${scenario.message}`);
|
|
91
|
+
result.availableProjectIds = scenario.availableProjectIds;
|
|
72
92
|
return result;
|
|
73
93
|
}
|
|
74
94
|
|
|
@@ -80,19 +100,15 @@ export async function executeScenario(scenarioName, page, params = {}, options =
|
|
|
80
100
|
const shouldExecute = await checkDependencyCondition(dep.condition, context);
|
|
81
101
|
|
|
82
102
|
if (!shouldExecute) {
|
|
83
|
-
debugLog(`Skipping scenario "${name}" due to condition`);
|
|
103
|
+
debugLog(`Skipping scenario "${item.name}" due to condition`);
|
|
84
104
|
continue;
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
}
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
// Load secrets
|
|
91
|
-
const secretsDir = path.join(baseDir, 'secrets');
|
|
92
|
-
const secrets = await loadSecrets(name, secretsDir);
|
|
93
|
-
|
|
94
110
|
// Merge secrets with params
|
|
95
|
-
const executionParams = { ...params, ...secrets };
|
|
111
|
+
const executionParams = { ...params, ...(scenario.secrets || {}) };
|
|
96
112
|
|
|
97
113
|
// Execute scenario
|
|
98
114
|
const scenarioResult = await executeSingleScenario(scenario, page, executionParams, {
|
|
@@ -100,7 +116,7 @@ export async function executeScenario(scenarioName, page, params = {}, options =
|
|
|
100
116
|
timeout
|
|
101
117
|
});
|
|
102
118
|
|
|
103
|
-
result.executedScenarios.push(name);
|
|
119
|
+
result.executedScenarios.push(item.name);
|
|
104
120
|
|
|
105
121
|
if (!scenarioResult.success) {
|
|
106
122
|
result.errors.push(...scenarioResult.errors);
|
|
@@ -170,7 +186,11 @@ async function executeSingleScenario(scenario, page, params = {}, options = {})
|
|
|
170
186
|
|
|
171
187
|
// Validate final URL if exitUrl is specified in metadata
|
|
172
188
|
if (scenario.metadata?.exitUrl) {
|
|
173
|
-
|
|
189
|
+
// Wait a bit for any pending navigation/redirects to complete
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
191
|
+
|
|
192
|
+
// Get current URL from the page (more reliable than page.url() for recent navigation)
|
|
193
|
+
const currentUrl = await page.evaluate(() => window.location.href);
|
|
174
194
|
const expectedUrl = scenario.metadata.exitUrl;
|
|
175
195
|
|
|
176
196
|
// Normalize URLs for comparison (remove trailing slashes, fragments)
|
|
@@ -4,21 +4,168 @@
|
|
|
4
4
|
* Manages scenario and secrets storage:
|
|
5
5
|
* 1. Save/load scenarios to/from files
|
|
6
6
|
* 2. Save/load secrets separately
|
|
7
|
-
* 3. Maintain
|
|
7
|
+
* 3. Maintain project-specific and global indexes
|
|
8
8
|
* 4. Ensure .gitignore for secrets directory
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import fs from 'fs/promises';
|
|
12
|
+
import fssync from 'fs';
|
|
12
13
|
import path from 'path';
|
|
14
|
+
import { homedir } from 'os';
|
|
13
15
|
|
|
14
16
|
// Constants
|
|
15
17
|
const INDEX_FILE = 'index.json';
|
|
16
18
|
const GITIGNORE_FILE = '.gitignore';
|
|
19
|
+
const BASE_STORAGE_DIR = path.join(homedir(), '.config', 'chrometools-mcp');
|
|
20
|
+
const GLOBAL_INDEX_PATH = path.join(BASE_STORAGE_DIR, 'index.json');
|
|
21
|
+
const PROJECTS_DIR = path.join(BASE_STORAGE_DIR, 'projects');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load global index from ~/.config/chrometools-mcp/index.json
|
|
25
|
+
* @returns {object} - Global index object
|
|
26
|
+
*/
|
|
27
|
+
function loadGlobalIndex() {
|
|
28
|
+
try {
|
|
29
|
+
const data = fssync.readFileSync(GLOBAL_INDEX_PATH, 'utf-8');
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Index doesn't exist yet, return empty structure
|
|
33
|
+
return {
|
|
34
|
+
version: '2.0',
|
|
35
|
+
projects: {}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save global index to ~/.config/chrometools-mcp/index.json
|
|
42
|
+
* @param {object} index - Global index object
|
|
43
|
+
*/
|
|
44
|
+
async function saveGlobalIndex(index) {
|
|
45
|
+
await fs.mkdir(BASE_STORAGE_DIR, { recursive: true });
|
|
46
|
+
await fs.writeFile(GLOBAL_INDEX_PATH, JSON.stringify(index, null, 2), 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Update global index with scenario metadata
|
|
51
|
+
* @param {string} projectId - Project identifier
|
|
52
|
+
* @param {string} projectPath - Full path to project root
|
|
53
|
+
* @param {string} scenarioName - Scenario name
|
|
54
|
+
* @param {object} metadata - Scenario metadata
|
|
55
|
+
*/
|
|
56
|
+
async function updateGlobalIndex(projectId, projectPath, scenarioName, metadata) {
|
|
57
|
+
const index = loadGlobalIndex();
|
|
58
|
+
|
|
59
|
+
if (!index.projects[projectId]) {
|
|
60
|
+
index.projects[projectId] = {
|
|
61
|
+
projectPath,
|
|
62
|
+
lastAccessed: new Date().toISOString(),
|
|
63
|
+
scenarios: {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
index.projects[projectId].scenarios[scenarioName] = {
|
|
68
|
+
name: scenarioName,
|
|
69
|
+
description: metadata.description || '',
|
|
70
|
+
tags: metadata.tags || [],
|
|
71
|
+
dependencies: metadata.dependencies || [],
|
|
72
|
+
parameters: metadata.parameters || {},
|
|
73
|
+
outputs: metadata.outputs || [],
|
|
74
|
+
entryUrl: metadata.entryUrl || null,
|
|
75
|
+
exitUrl: metadata.exitUrl || null,
|
|
76
|
+
createdAt: index.projects[projectId].scenarios[scenarioName]?.createdAt || new Date().toISOString(),
|
|
77
|
+
updatedAt: new Date().toISOString()
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
index.projects[projectId].lastAccessed = new Date().toISOString();
|
|
81
|
+
|
|
82
|
+
await saveGlobalIndex(index);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove scenario from global index
|
|
87
|
+
* @param {string} projectId - Project identifier
|
|
88
|
+
* @param {string} scenarioName - Scenario name
|
|
89
|
+
*/
|
|
90
|
+
async function removeFromGlobalIndex(projectId, scenarioName) {
|
|
91
|
+
const index = loadGlobalIndex();
|
|
92
|
+
|
|
93
|
+
if (index.projects[projectId]?.scenarios[scenarioName]) {
|
|
94
|
+
delete index.projects[projectId].scenarios[scenarioName];
|
|
95
|
+
|
|
96
|
+
// Remove project if no scenarios left
|
|
97
|
+
if (Object.keys(index.projects[projectId].scenarios).length === 0) {
|
|
98
|
+
delete index.projects[projectId];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await saveGlobalIndex(index);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find scenario in global index
|
|
107
|
+
* @param {string} scenarioName - Scenario name
|
|
108
|
+
* @param {string|null} projectId - Optional project filter
|
|
109
|
+
* @returns {object|null} - { projectId, metadata } or null
|
|
110
|
+
*/
|
|
111
|
+
/**
|
|
112
|
+
* Find all scenarios with the given name across all projects
|
|
113
|
+
* @param {string} scenarioName - Scenario name
|
|
114
|
+
* @returns {Array} - Array of { projectId, metadata } objects
|
|
115
|
+
*/
|
|
116
|
+
function findAllScenariosWithName(scenarioName) {
|
|
117
|
+
const index = loadGlobalIndex();
|
|
118
|
+
const results = [];
|
|
119
|
+
|
|
120
|
+
for (const [pid, project] of Object.entries(index.projects)) {
|
|
121
|
+
if (project.scenarios[scenarioName]) {
|
|
122
|
+
results.push({
|
|
123
|
+
projectId: pid,
|
|
124
|
+
metadata: project.scenarios[scenarioName]
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function findScenarioInGlobalIndex(scenarioName, projectId = null) {
|
|
133
|
+
const index = loadGlobalIndex();
|
|
134
|
+
|
|
135
|
+
if (projectId) {
|
|
136
|
+
// Search in specific project
|
|
137
|
+
if (index.projects[projectId]?.scenarios[scenarioName]) {
|
|
138
|
+
return {
|
|
139
|
+
projectId,
|
|
140
|
+
metadata: index.projects[projectId].scenarios[scenarioName]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Search in all projects - check for collisions
|
|
147
|
+
const allMatches = findAllScenariosWithName(scenarioName);
|
|
148
|
+
|
|
149
|
+
if (allMatches.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (allMatches.length > 1) {
|
|
154
|
+
// Multiple scenarios with same name - return collision error
|
|
155
|
+
return {
|
|
156
|
+
collision: true,
|
|
157
|
+
matches: allMatches.map(m => m.projectId)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Single match - return it
|
|
162
|
+
return allMatches[0];
|
|
163
|
+
}
|
|
17
164
|
|
|
18
165
|
/**
|
|
19
166
|
* Initialize storage directories
|
|
20
167
|
* Creates directories and ensures .gitignore exists
|
|
21
|
-
* @param {string} baseDir - Base directory for scenarios and secrets
|
|
168
|
+
* @param {string} baseDir - Base directory for scenarios and secrets (project directory)
|
|
22
169
|
*/
|
|
23
170
|
export async function initializeStorage(baseDir) {
|
|
24
171
|
const scenariosDir = path.join(baseDir, 'scenarios');
|
|
@@ -64,12 +211,18 @@ async function ensureSecretsGitignore(secretsDir) {
|
|
|
64
211
|
/**
|
|
65
212
|
* Save scenario to file
|
|
66
213
|
* @param {Object} scenario - Scenario data
|
|
67
|
-
* @param {string}
|
|
214
|
+
* @param {string} urlProjectId - Project identifier extracted from URL (e.g., "google", "localhost-3000")
|
|
68
215
|
* @returns {Object} - { success: boolean, path: string, error?: string }
|
|
69
216
|
*/
|
|
70
|
-
export async function saveScenario(scenario,
|
|
217
|
+
export async function saveScenario(scenario, urlProjectId) {
|
|
71
218
|
try {
|
|
72
|
-
|
|
219
|
+
// Use URL-based projectId directly
|
|
220
|
+
const projectId = urlProjectId;
|
|
221
|
+
const projectPath = `url://${urlProjectId}`;
|
|
222
|
+
|
|
223
|
+
// Get project directory
|
|
224
|
+
const projectDir = path.join(PROJECTS_DIR, projectId);
|
|
225
|
+
await initializeStorage(projectDir);
|
|
73
226
|
|
|
74
227
|
const { name, metadata, chain, secrets } = scenario;
|
|
75
228
|
|
|
@@ -81,13 +234,20 @@ export async function saveScenario(scenario, baseDir) {
|
|
|
81
234
|
};
|
|
82
235
|
}
|
|
83
236
|
|
|
84
|
-
const scenariosDir = path.join(
|
|
85
|
-
const secretsDir = path.join(
|
|
237
|
+
const scenariosDir = path.join(projectDir, 'scenarios');
|
|
238
|
+
const secretsDir = path.join(projectDir, 'secrets');
|
|
239
|
+
|
|
240
|
+
// Add project info to metadata
|
|
241
|
+
const enhancedMetadata = {
|
|
242
|
+
...(metadata || {}),
|
|
243
|
+
projectId,
|
|
244
|
+
projectPath
|
|
245
|
+
};
|
|
86
246
|
|
|
87
247
|
// Save main scenario file (without secrets)
|
|
88
248
|
const scenarioData = {
|
|
89
249
|
name,
|
|
90
|
-
metadata:
|
|
250
|
+
metadata: enhancedMetadata,
|
|
91
251
|
chain,
|
|
92
252
|
version: '1.0',
|
|
93
253
|
createdAt: new Date().toISOString()
|
|
@@ -101,8 +261,11 @@ export async function saveScenario(scenario, baseDir) {
|
|
|
101
261
|
await saveSecrets(name, secrets, secretsDir);
|
|
102
262
|
}
|
|
103
263
|
|
|
104
|
-
// Update index
|
|
105
|
-
await updateIndex(name,
|
|
264
|
+
// Update project-local index
|
|
265
|
+
await updateIndex(name, enhancedMetadata, scenariosDir);
|
|
266
|
+
|
|
267
|
+
// Update global index
|
|
268
|
+
await updateGlobalIndex(projectId, projectPath, name, enhancedMetadata);
|
|
106
269
|
|
|
107
270
|
return {
|
|
108
271
|
success: true,
|
|
@@ -120,19 +283,40 @@ export async function saveScenario(scenario, baseDir) {
|
|
|
120
283
|
* Load scenario from file
|
|
121
284
|
* @param {string} name - Scenario name
|
|
122
285
|
* @param {boolean} includeSecrets - Whether to load secrets
|
|
123
|
-
* @param {string}
|
|
286
|
+
* @param {string|null} projectId - Optional project ID filter
|
|
124
287
|
* @returns {Object} - Scenario data or null
|
|
125
288
|
*/
|
|
126
|
-
export async function loadScenario(name, includeSecrets = false,
|
|
289
|
+
export async function loadScenario(name, includeSecrets = false, projectId = null) {
|
|
127
290
|
try {
|
|
128
|
-
|
|
291
|
+
// Find scenario in global index
|
|
292
|
+
const result = findScenarioInGlobalIndex(name, projectId);
|
|
293
|
+
|
|
294
|
+
if (!result) {
|
|
295
|
+
console.error(`Scenario "${name}" not found${projectId ? ` in project "${projectId}"` : ''}`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check for name collision
|
|
300
|
+
if (result.collision) {
|
|
301
|
+
const error = {
|
|
302
|
+
collision: true,
|
|
303
|
+
message: `Multiple scenarios named "${name}" found. Please specify projectId.`,
|
|
304
|
+
availableProjectIds: result.matches
|
|
305
|
+
};
|
|
306
|
+
console.error(error.message, 'Available projectIds:', result.matches);
|
|
307
|
+
return error;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Load from project directory
|
|
311
|
+
const projectDir = path.join(PROJECTS_DIR, result.projectId);
|
|
312
|
+
const scenariosDir = path.join(projectDir, 'scenarios');
|
|
129
313
|
const scenarioPath = path.join(scenariosDir, `${name}.json`);
|
|
130
314
|
const content = await fs.readFile(scenarioPath, 'utf-8');
|
|
131
315
|
const scenario = JSON.parse(content);
|
|
132
316
|
|
|
133
317
|
// Load secrets if requested
|
|
134
318
|
if (includeSecrets) {
|
|
135
|
-
const secretsDir = path.join(
|
|
319
|
+
const secretsDir = path.join(projectDir, 'secrets');
|
|
136
320
|
const secrets = await loadSecrets(name, secretsDir);
|
|
137
321
|
if (secrets) {
|
|
138
322
|
scenario.secrets = secrets;
|
|
@@ -235,25 +419,51 @@ async function saveIndex(index, scenariosDir) {
|
|
|
235
419
|
|
|
236
420
|
/**
|
|
237
421
|
* List all available scenarios
|
|
238
|
-
* @param {string}
|
|
422
|
+
* @param {string} currentProjectId - Current project identifier
|
|
423
|
+
* @param {boolean} allProjects - Whether to list scenarios from all projects
|
|
239
424
|
* @returns {Array} - Array of scenario metadata
|
|
240
425
|
*/
|
|
241
|
-
export async function listScenarios(
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
426
|
+
export async function listScenarios(currentProjectId, allProjects = false) {
|
|
427
|
+
const globalIndex = loadGlobalIndex();
|
|
428
|
+
const results = [];
|
|
429
|
+
|
|
430
|
+
if (allProjects) {
|
|
431
|
+
// Return scenarios from all projects
|
|
432
|
+
for (const [projectId, project] of Object.entries(globalIndex.projects)) {
|
|
433
|
+
for (const scenario of Object.values(project.scenarios)) {
|
|
434
|
+
results.push({
|
|
435
|
+
...scenario,
|
|
436
|
+
projectId,
|
|
437
|
+
projectPath: project.projectPath
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
// Return scenarios from current project only
|
|
443
|
+
if (globalIndex.projects[currentProjectId]) {
|
|
444
|
+
for (const scenario of Object.values(globalIndex.projects[currentProjectId].scenarios)) {
|
|
445
|
+
results.push({
|
|
446
|
+
...scenario,
|
|
447
|
+
projectId: currentProjectId,
|
|
448
|
+
projectPath: globalIndex.projects[currentProjectId].projectPath
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return results;
|
|
245
455
|
}
|
|
246
456
|
|
|
247
457
|
/**
|
|
248
458
|
* Search scenarios by query
|
|
249
459
|
* @param {Object} query - Search query { tags?, text?, dependencies? }
|
|
250
|
-
* @param {string}
|
|
460
|
+
* @param {string} currentProjectId - Current project identifier
|
|
461
|
+
* @param {boolean} allProjects - Whether to search in all projects
|
|
251
462
|
* @returns {Array} - Matching scenarios
|
|
252
463
|
*/
|
|
253
|
-
export async function searchScenarios(query,
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
const scenarios = Object.values(index);
|
|
464
|
+
export async function searchScenarios(query, currentProjectId, allProjects = false) {
|
|
465
|
+
// Get all scenarios based on allProjects flag
|
|
466
|
+
const scenarios = await listScenarios(currentProjectId, allProjects);
|
|
257
467
|
|
|
258
468
|
let results = scenarios;
|
|
259
469
|
|
|
@@ -286,13 +496,22 @@ export async function searchScenarios(query, baseDir) {
|
|
|
286
496
|
/**
|
|
287
497
|
* Delete scenario
|
|
288
498
|
* @param {string} name - Scenario name
|
|
289
|
-
* @param {string}
|
|
499
|
+
* @param {string|null} projectId - Optional project ID filter
|
|
290
500
|
* @returns {boolean} - Success
|
|
291
501
|
*/
|
|
292
|
-
export async function deleteScenario(name,
|
|
502
|
+
export async function deleteScenario(name, projectId = null) {
|
|
293
503
|
try {
|
|
294
|
-
|
|
295
|
-
const
|
|
504
|
+
// Find scenario in global index
|
|
505
|
+
const result = findScenarioInGlobalIndex(name, projectId);
|
|
506
|
+
|
|
507
|
+
if (!result) {
|
|
508
|
+
console.error(`Scenario "${name}" not found${projectId ? ` in project "${projectId}"` : ''}`);
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const projectDir = path.join(PROJECTS_DIR, result.projectId);
|
|
513
|
+
const scenariosDir = path.join(projectDir, 'scenarios');
|
|
514
|
+
const secretsDir = path.join(projectDir, 'secrets');
|
|
296
515
|
|
|
297
516
|
// Delete scenario file
|
|
298
517
|
const scenarioPath = path.join(scenariosDir, `${name}.json`);
|
|
@@ -306,11 +525,14 @@ export async function deleteScenario(name, baseDir) {
|
|
|
306
525
|
// Secrets file may not exist
|
|
307
526
|
}
|
|
308
527
|
|
|
309
|
-
// Remove from index
|
|
528
|
+
// Remove from project-local index
|
|
310
529
|
const index = await loadIndex(scenariosDir);
|
|
311
530
|
delete index[name];
|
|
312
531
|
await saveIndex(index, scenariosDir);
|
|
313
532
|
|
|
533
|
+
// Remove from global index
|
|
534
|
+
await removeFromGlobalIndex(result.projectId, name);
|
|
535
|
+
|
|
314
536
|
return true;
|
|
315
537
|
} catch (error) {
|
|
316
538
|
console.error(`Error deleting scenario "${name}":`, error.message);
|