zelune 0.0.0-stage → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/DESIGN.md +90 -0
- package/PRODUCT.md +31 -0
- package/README.md +63 -2
- package/assets/logo.svg +8 -0
- package/bin/cli.mjs +427 -0
- package/index.html +18 -0
- package/mcp/server.ts +196 -0
- package/mcp_config.json +8 -0
- package/package.json +77 -4
- package/plugin.json +12 -0
- package/plugins/viteRnSourceInject.ts +117 -0
- package/plugins/viteVirtualScreensPlugin.ts +85 -0
- package/rules/AGENTS.md +21 -0
- package/skills/rn-canvas/SKILL.md +33 -0
- package/src/canvas/CanvasApp.tsx +1288 -0
- package/src/canvas/FlowArrowsLayer.tsx +197 -0
- package/src/canvas/InspectorPanel.tsx +979 -0
- package/src/canvas/ScreenArtboard.tsx +1416 -0
- package/src/canvas/types.ts +23 -0
- package/src/main.tsx +10 -0
- package/src/runtime/MockNavigationContext.tsx +199 -0
- package/src/runtime/ScreenErrorBoundary.tsx +86 -0
- package/src/screens/CheckoutScreen.tsx +229 -0
- package/src/screens/HomeScreen.tsx +288 -0
- package/src/screens/LoginScreen.tsx +176 -0
- package/src/screens/ProductDetailScreen.tsx +234 -0
- package/src/screens/ProfileScreen.tsx +172 -0
- package/src/screens/index.ts +1 -0
- package/src/server/apiMiddleware.ts +327 -0
- package/src/server/astEngine.ts +1286 -0
- package/src/styles.css +298 -0
- package/src/vite-env.d.ts +7 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +148 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const defaultPackageRoot = path.resolve(__dirname, '..');
|
|
11
|
+
const requireFromCli = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
const SCREEN_DIR_CANDIDATES = ['src/screens', 'screens', 'app', 'src/app'];
|
|
14
|
+
const IGNORED_SCAN_DIRS = new Set([
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.git',
|
|
17
|
+
'dist',
|
|
18
|
+
'build',
|
|
19
|
+
'.next',
|
|
20
|
+
'.expo',
|
|
21
|
+
'.turbo',
|
|
22
|
+
'ios',
|
|
23
|
+
'android',
|
|
24
|
+
'vendor',
|
|
25
|
+
'coverage',
|
|
26
|
+
'.agents',
|
|
27
|
+
'.gemini',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function isHostPortFree(port, host) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const server = net.createServer();
|
|
33
|
+
server.unref();
|
|
34
|
+
server.on('error', (err) => {
|
|
35
|
+
if (err && (err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')) {
|
|
36
|
+
resolve(true);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
resolve(false);
|
|
40
|
+
});
|
|
41
|
+
server.listen({ port, host, exclusive: true }, () => {
|
|
42
|
+
server.close(() => resolve(true));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function isPortFree(port) {
|
|
48
|
+
const [ipv4Free, ipv6Free] = await Promise.all([
|
|
49
|
+
isHostPortFree(port, '127.0.0.1'),
|
|
50
|
+
isHostPortFree(port, '::1'),
|
|
51
|
+
]);
|
|
52
|
+
return ipv4Free && ipv6Free;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function findAvailablePort(startPort = 5173, maxAttempts = 25) {
|
|
56
|
+
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
57
|
+
const candidate = startPort + offset;
|
|
58
|
+
if (await isPortFree(candidate)) {
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return startPort;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countScreenFilesInDirectory(screensDir, maxDepth = 4) {
|
|
66
|
+
let count = 0;
|
|
67
|
+
function walk(curr, depth) {
|
|
68
|
+
if (depth > maxDepth) return;
|
|
69
|
+
let entries = [];
|
|
70
|
+
try {
|
|
71
|
+
entries = fs.readdirSync(curr, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.name.startsWith('.')) continue;
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (
|
|
79
|
+
entry.name === 'node_modules' ||
|
|
80
|
+
entry.name === '__tests__' ||
|
|
81
|
+
entry.name === 'components'
|
|
82
|
+
) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
walk(path.join(curr, entry.name), depth + 1);
|
|
86
|
+
} else if (entry.isFile()) {
|
|
87
|
+
const f = entry.name;
|
|
88
|
+
if (f.startsWith('_') || f.startsWith('+')) continue;
|
|
89
|
+
if (f === 'index.ts' || f === 'index.js') continue;
|
|
90
|
+
if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
|
|
91
|
+
if (f.endsWith('.tsx') || f.endsWith('.jsx')) {
|
|
92
|
+
count++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(screensDir, 0);
|
|
98
|
+
return count;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasDirectScreens(dirPath) {
|
|
102
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
103
|
+
const full = path.join(dirPath, rel);
|
|
104
|
+
try {
|
|
105
|
+
if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {
|
|
106
|
+
const count = countScreenFilesInDirectory(full);
|
|
107
|
+
if (count > 0) {
|
|
108
|
+
return { found: true, relDir: rel, count };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { found: false, relDir: null, count: 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function scoreMobileCandidate(dirPath, screenCount) {
|
|
119
|
+
let score = screenCount;
|
|
120
|
+
const folderName = path.basename(dirPath).toLowerCase();
|
|
121
|
+
if (/mobile|native|expo|app|client/.test(folderName)) {
|
|
122
|
+
score += 10;
|
|
123
|
+
}
|
|
124
|
+
if (fs.existsSync(path.join(dirPath, 'app.json')) || fs.existsSync(path.join(dirPath, 'eas.json'))) {
|
|
125
|
+
score += 25;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const pkgPath = path.join(dirPath, 'package.json');
|
|
129
|
+
if (fs.existsSync(pkgPath)) {
|
|
130
|
+
const pkgRaw = fs.readFileSync(pkgPath, 'utf8');
|
|
131
|
+
if (pkgRaw.includes('"react-native"') || pkgRaw.includes('"expo"')) {
|
|
132
|
+
score += 20;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore
|
|
137
|
+
}
|
|
138
|
+
return score;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function findMobileAppRoot(rawRoot) {
|
|
142
|
+
const normalizedRoot = path.resolve(rawRoot);
|
|
143
|
+
if (!fs.existsSync(normalizedRoot)) {
|
|
144
|
+
return normalizedRoot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const direct = hasDirectScreens(normalizedRoot);
|
|
148
|
+
if (direct.found) {
|
|
149
|
+
return normalizedRoot;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let bestCandidate = null;
|
|
153
|
+
let bestScore = -1;
|
|
154
|
+
|
|
155
|
+
function scanSubdirs(parentDir, depth) {
|
|
156
|
+
if (depth > 2) return;
|
|
157
|
+
let entries = [];
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
166
|
+
if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
|
|
167
|
+
|
|
168
|
+
const subPath = path.join(parentDir, entry.name);
|
|
169
|
+
const check = hasDirectScreens(subPath);
|
|
170
|
+
if (check.found) {
|
|
171
|
+
const score = scoreMobileCandidate(subPath, check.count);
|
|
172
|
+
if (score > bestScore) {
|
|
173
|
+
bestScore = score;
|
|
174
|
+
bestCandidate = subPath;
|
|
175
|
+
}
|
|
176
|
+
} else if (depth < 2) {
|
|
177
|
+
scanSubdirs(subPath, depth + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
scanSubdirs(normalizedRoot, 1);
|
|
183
|
+
return bestCandidate ?? normalizedRoot;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function detectScreensDirectory(targetProject) {
|
|
187
|
+
const mobileRoot = findMobileAppRoot(targetProject);
|
|
188
|
+
// First check directories with .tsx/.jsx screen files
|
|
189
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
190
|
+
const full = path.join(mobileRoot, rel);
|
|
191
|
+
if (fs.existsSync(full) && fs.statSync(full).isDirectory() && countScreenFilesInDirectory(full) > 0) {
|
|
192
|
+
const relFromTarget = path.relative(targetProject, full).replace(/\\/g, '/');
|
|
193
|
+
return relFromTarget || rel;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Fallback to existing empty candidate directory (e.g. newly initialized project)
|
|
197
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
198
|
+
const full = path.join(mobileRoot, rel);
|
|
199
|
+
if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {
|
|
200
|
+
const relFromTarget = path.relative(targetProject, full).replace(/\\/g, '/');
|
|
201
|
+
return relFromTarget || rel;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function addCanvasScriptToPackageJson(dirPath) {
|
|
208
|
+
const pkgJsonPath = path.join(dirPath, 'package.json');
|
|
209
|
+
if (!fs.existsSync(pkgJsonPath)) return false;
|
|
210
|
+
try {
|
|
211
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
212
|
+
pkg.scripts = pkg.scripts || {};
|
|
213
|
+
if (pkg.name !== 'zelune' && !pkg.scripts.canvas) {
|
|
214
|
+
pkg.scripts.canvas = 'zelune';
|
|
215
|
+
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore malformed package.json
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function initProject(targetProject, packageRoot = defaultPackageRoot) {
|
|
225
|
+
const resolvedTarget = path.resolve(targetProject);
|
|
226
|
+
const mobileRoot = findMobileAppRoot(resolvedTarget);
|
|
227
|
+
const screensDir = detectScreensDirectory(resolvedTarget);
|
|
228
|
+
|
|
229
|
+
// 1. Update package.json with "canvas": "zelune" script in target root (and nested mobile root if different)
|
|
230
|
+
let updatedPackageJson = addCanvasScriptToPackageJson(resolvedTarget);
|
|
231
|
+
if (mobileRoot !== resolvedTarget) {
|
|
232
|
+
const updatedNested = addCanvasScriptToPackageJson(mobileRoot);
|
|
233
|
+
updatedPackageJson = updatedPackageJson || updatedNested;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 2. Create or merge mcp_config.json with the zelune MCP server entry
|
|
237
|
+
const mcpConfigPath = path.join(resolvedTarget, 'mcp_config.json');
|
|
238
|
+
let mcpConfig = { mcpServers: {} };
|
|
239
|
+
if (fs.existsSync(mcpConfigPath)) {
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
|
|
242
|
+
if (parsed && typeof parsed === 'object') {
|
|
243
|
+
mcpConfig = parsed;
|
|
244
|
+
mcpConfig.mcpServers = mcpConfig.mcpServers || {};
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
mcpConfig = { mcpServers: {} };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
mcpConfig.mcpServers.zelune = {
|
|
251
|
+
command: 'npx',
|
|
252
|
+
args: ['-y', 'zelune', '--mcp', '--project', mobileRoot],
|
|
253
|
+
};
|
|
254
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n', 'utf8');
|
|
255
|
+
|
|
256
|
+
// 3. Install AI Agent Skill into .agents/skills/zelune/SKILL.md
|
|
257
|
+
const sourceSkillPath = path.join(packageRoot, 'skills/rn-canvas/SKILL.md');
|
|
258
|
+
const destSkillDir = path.join(resolvedTarget, '.agents/skills/zelune');
|
|
259
|
+
const destSkillPath = path.join(destSkillDir, 'SKILL.md');
|
|
260
|
+
if (fs.existsSync(sourceSkillPath)) {
|
|
261
|
+
fs.mkdirSync(destSkillDir, { recursive: true });
|
|
262
|
+
fs.copyFileSync(sourceSkillPath, destSkillPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 4. Copy starter DESIGN.md & PRODUCT.md if not already present
|
|
266
|
+
let createdDesignMd = false;
|
|
267
|
+
const sourceDesignPath = path.join(packageRoot, 'DESIGN.md');
|
|
268
|
+
const destDesignPath = path.join(resolvedTarget, 'DESIGN.md');
|
|
269
|
+
if (fs.existsSync(sourceDesignPath) && !fs.existsSync(destDesignPath)) {
|
|
270
|
+
fs.copyFileSync(sourceDesignPath, destDesignPath);
|
|
271
|
+
createdDesignMd = true;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let createdProductMd = false;
|
|
275
|
+
const sourceProductPath = path.join(packageRoot, 'PRODUCT.md');
|
|
276
|
+
const destProductPath = path.join(resolvedTarget, 'PRODUCT.md');
|
|
277
|
+
if (fs.existsSync(sourceProductPath) && !fs.existsSync(destProductPath)) {
|
|
278
|
+
fs.copyFileSync(sourceProductPath, destProductPath);
|
|
279
|
+
createdProductMd = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 5. Ensure .canvas-layout.json is listed in .gitignore
|
|
283
|
+
const gitignorePath = path.join(resolvedTarget, '.gitignore');
|
|
284
|
+
if (fs.existsSync(gitignorePath)) {
|
|
285
|
+
const currentIgnore = fs.readFileSync(gitignorePath, 'utf8');
|
|
286
|
+
if (!currentIgnore.split(/\r?\n/).includes('.canvas-layout.json')) {
|
|
287
|
+
const prefix = currentIgnore.endsWith('\n') || currentIgnore.length === 0 ? '' : '\n';
|
|
288
|
+
fs.writeFileSync(
|
|
289
|
+
gitignorePath,
|
|
290
|
+
`${currentIgnore}${prefix}.canvas-layout.json\n`,
|
|
291
|
+
'utf8'
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
fs.writeFileSync(gitignorePath, '.canvas-layout.json\n', 'utf8');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
targetProject: resolvedTarget,
|
|
300
|
+
mobileRoot,
|
|
301
|
+
screensDir: screensDir ?? 'src/screens (or app/)',
|
|
302
|
+
updatedPackageJson,
|
|
303
|
+
mcpConfigPath,
|
|
304
|
+
skillPath: destSkillPath,
|
|
305
|
+
createdDesignMd,
|
|
306
|
+
createdProductMd,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function runCli() {
|
|
311
|
+
const args = process.argv.slice(2);
|
|
312
|
+
|
|
313
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
314
|
+
console.log(`
|
|
315
|
+
Zelune — React Native Visual Canvas & AST Sync MCP Server
|
|
316
|
+
|
|
317
|
+
Usage:
|
|
318
|
+
zelune Launch visual canvas for current project (auto-selects free port & opens browser)
|
|
319
|
+
zelune init One-command setup for the current React Native / Expo project
|
|
320
|
+
zelune --project <dir> Target a specific React Native / Expo project directory
|
|
321
|
+
zelune --port <number> Use a specific port (default: auto-detects from 5173)
|
|
322
|
+
zelune --no-open Do not automatically open the browser on launch
|
|
323
|
+
zelune --mcp Start the Zelune stdio MCP server for Google Antigravity / Cursor / Claude Code
|
|
324
|
+
`);
|
|
325
|
+
process.exit(0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const isMcpMode = args.includes('--mcp');
|
|
329
|
+
const isInitMode = args[0] === 'init' || args[0] === 'setup';
|
|
330
|
+
|
|
331
|
+
let targetProject = process.cwd();
|
|
332
|
+
const projectFlagIdx = args.indexOf('--project');
|
|
333
|
+
if (projectFlagIdx !== -1 && args[projectFlagIdx + 1]) {
|
|
334
|
+
targetProject = path.resolve(args[projectFlagIdx + 1]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (isInitMode) {
|
|
338
|
+
const result = initProject(targetProject, defaultPackageRoot);
|
|
339
|
+
console.log(`\n✨ Zelune initialized in ${result.targetProject}`);
|
|
340
|
+
if (result.mobileRoot !== result.targetProject) {
|
|
341
|
+
console.log(` • Detected nested mobile app: ${result.mobileRoot}`);
|
|
342
|
+
}
|
|
343
|
+
console.log(` • Screens directory: ${result.screensDir}`);
|
|
344
|
+
if (result.updatedPackageJson) {
|
|
345
|
+
console.log(` • Added "canvas": "zelune" script to package.json`);
|
|
346
|
+
}
|
|
347
|
+
console.log(` • Configured MCP server in mcp_config.json`);
|
|
348
|
+
console.log(` • Installed AI skill in .agents/skills/zelune/SKILL.md`);
|
|
349
|
+
if (result.createdDesignMd || result.createdProductMd) {
|
|
350
|
+
console.log(` • Created starter DESIGN.md & PRODUCT.md governance docs`);
|
|
351
|
+
}
|
|
352
|
+
console.log(`\n👉 Run "npx zelune" (or "npm run canvas") to open your screens in the visual canvas!\n`);
|
|
353
|
+
process.exit(0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const resolvedMobileRoot = findMobileAppRoot(targetProject);
|
|
357
|
+
const env = {
|
|
358
|
+
...process.env,
|
|
359
|
+
ANTIGRAVITY_TARGET_PROJECT: resolvedMobileRoot,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (isMcpMode) {
|
|
363
|
+
const mcpServerPath = path.join(defaultPackageRoot, 'mcp/server.ts');
|
|
364
|
+
const child = spawn('npx', ['tsx', mcpServerPath], {
|
|
365
|
+
cwd: defaultPackageRoot,
|
|
366
|
+
env,
|
|
367
|
+
stdio: 'inherit',
|
|
368
|
+
});
|
|
369
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const portFlagIdx = args.indexOf('--port');
|
|
374
|
+
let port;
|
|
375
|
+
if (portFlagIdx !== -1 && args[portFlagIdx + 1]) {
|
|
376
|
+
const parsedPort = Number.parseInt(String(args[portFlagIdx + 1]), 10);
|
|
377
|
+
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
|
378
|
+
console.error(
|
|
379
|
+
`Error: Invalid --port value "${args[portFlagIdx + 1]}". Must be an integer between 1 and 65535.`
|
|
380
|
+
);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
port = String(parsedPort);
|
|
384
|
+
} else {
|
|
385
|
+
const resolvedPort = await findAvailablePort(5173);
|
|
386
|
+
port = String(resolvedPort);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const shouldOpenBrowser = !args.includes('--no-open');
|
|
390
|
+
const viteCliFlags = ['--host', '127.0.0.1', '--port', port];
|
|
391
|
+
if (shouldOpenBrowser) {
|
|
392
|
+
viteCliFlags.push('--open');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log(`\n🚀 Zelune — React Native Visual Canvas & MCP`);
|
|
396
|
+
console.log(`📂 Workspace Root: ${targetProject}`);
|
|
397
|
+
if (resolvedMobileRoot !== targetProject) {
|
|
398
|
+
console.log(`📱 Mobile App Dir: ${resolvedMobileRoot}`);
|
|
399
|
+
}
|
|
400
|
+
console.log(`🌐 Studio URL: http://127.0.0.1:${port}\n`);
|
|
401
|
+
|
|
402
|
+
let child;
|
|
403
|
+
try {
|
|
404
|
+
const viteBinPath = requireFromCli.resolve('vite/bin/vite.js');
|
|
405
|
+
child = spawn(process.execPath, [viteBinPath, ...viteCliFlags], {
|
|
406
|
+
cwd: defaultPackageRoot,
|
|
407
|
+
env,
|
|
408
|
+
stdio: 'inherit',
|
|
409
|
+
});
|
|
410
|
+
} catch {
|
|
411
|
+
child = spawn('npx', ['vite', ...viteCliFlags], {
|
|
412
|
+
cwd: defaultPackageRoot,
|
|
413
|
+
env,
|
|
414
|
+
stdio: 'inherit',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const isDirectExecution =
|
|
422
|
+
process.argv[1] &&
|
|
423
|
+
import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
|
|
424
|
+
|
|
425
|
+
if (isDirectExecution) {
|
|
426
|
+
runCli();
|
|
427
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Zelune — React Native Visual Canvas & AST Studio</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
|
|
11
|
+
rel="stylesheet"
|
|
12
|
+
/>
|
|
13
|
+
</head>
|
|
14
|
+
<body class="bg-[#08090C] text-[#F1F5F9] antialiased selection:bg-sky-500/25 overflow-hidden">
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
package/mcp/server.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import {
|
|
10
|
+
discoverScreens,
|
|
11
|
+
inspectElement,
|
|
12
|
+
patchElement,
|
|
13
|
+
reorderElement,
|
|
14
|
+
type StyleValue,
|
|
15
|
+
} from '../src/server/astEngine.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const WORKSPACE_ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
const server = new Server(
|
|
21
|
+
{
|
|
22
|
+
name: 'zelune',
|
|
23
|
+
version: '1.5.0',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
capabilities: {
|
|
27
|
+
tools: {},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
33
|
+
return {
|
|
34
|
+
tools: [
|
|
35
|
+
{
|
|
36
|
+
name: 'list_canvas_screens',
|
|
37
|
+
description:
|
|
38
|
+
'Lists all discovered React Native screens on the visual canvas along with their element counts and outgoing navigation flow edges.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'inspect_screen_element',
|
|
46
|
+
description:
|
|
47
|
+
'Inspects a specific JSX element inside a React Native screen by its relative file path and AST index path.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
file: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Relative path to the screen file (e.g. src/screens/HomeScreen.tsx)',
|
|
54
|
+
},
|
|
55
|
+
astPath: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Preorder AST index of the JSX element (e.g. "0", "4")',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ['file', 'astPath'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'patch_screen_element',
|
|
65
|
+
description:
|
|
66
|
+
'Surgically patches the text copy or StyleSheet properties of a React Native JSX element.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
file: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'Relative path to the screen file (e.g. src/screens/HomeScreen.tsx)',
|
|
73
|
+
},
|
|
74
|
+
astPath: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: 'Preorder AST index of the JSX element',
|
|
77
|
+
},
|
|
78
|
+
text: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Optional new static text content for <Text> elements',
|
|
81
|
+
},
|
|
82
|
+
styles: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
description: 'Optional map of React Native style properties to update',
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
required: ['file', 'astPath'],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'reorder_screen_element',
|
|
92
|
+
description:
|
|
93
|
+
'Surgically reorders a React Native JSX element among its Flexbox siblings in the .tsx AST.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
file: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Relative path to the screen file (e.g. src/screens/HomeScreen.tsx)',
|
|
100
|
+
},
|
|
101
|
+
astPath: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description: 'Preorder AST index of the JSX element to move',
|
|
104
|
+
},
|
|
105
|
+
direction: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
enum: ['up', 'down'],
|
|
108
|
+
description: 'Optional relative direction ("up" or "down") among siblings',
|
|
109
|
+
},
|
|
110
|
+
targetAstPath: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'Optional AST index of the sibling to place before/after',
|
|
113
|
+
},
|
|
114
|
+
placement: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
enum: ['before', 'after'],
|
|
117
|
+
description: 'Whether to insert before or after targetAstPath',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ['file', 'astPath'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
128
|
+
const { name, arguments: args } = request.params;
|
|
129
|
+
|
|
130
|
+
if (name === 'list_canvas_screens') {
|
|
131
|
+
const screens = await discoverScreens(WORKSPACE_ROOT);
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: 'text', text: JSON.stringify(screens, null, 2) }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (name === 'inspect_screen_element') {
|
|
138
|
+
const file = String(args?.file ?? '');
|
|
139
|
+
const astPath = String(args?.astPath ?? '0');
|
|
140
|
+
const element = await inspectElement(WORKSPACE_ROOT, { file, astPath });
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: 'text', text: JSON.stringify(element, null, 2) }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (name === 'patch_screen_element') {
|
|
147
|
+
const file = String(args?.file ?? '');
|
|
148
|
+
const astPath = String(args?.astPath ?? '0');
|
|
149
|
+
const text = typeof args?.text === 'string' ? args.text : undefined;
|
|
150
|
+
const styles = (args?.styles as Record<string, StyleValue> | undefined) ?? undefined;
|
|
151
|
+
|
|
152
|
+
const result = await patchElement(WORKSPACE_ROOT, {
|
|
153
|
+
file,
|
|
154
|
+
astPath,
|
|
155
|
+
text,
|
|
156
|
+
styles,
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (name === 'reorder_screen_element') {
|
|
164
|
+
const file = String(args?.file ?? '');
|
|
165
|
+
const astPath = String(args?.astPath ?? '0');
|
|
166
|
+
const direction =
|
|
167
|
+
args?.direction === 'up' || args?.direction === 'down' ? args.direction : undefined;
|
|
168
|
+
const targetAstPath =
|
|
169
|
+
typeof args?.targetAstPath === 'string' ? args.targetAstPath : undefined;
|
|
170
|
+
const placement =
|
|
171
|
+
args?.placement === 'before' || args?.placement === 'after' ? args.placement : undefined;
|
|
172
|
+
|
|
173
|
+
const result = await reorderElement(WORKSPACE_ROOT, {
|
|
174
|
+
file,
|
|
175
|
+
astPath,
|
|
176
|
+
direction,
|
|
177
|
+
targetAstPath,
|
|
178
|
+
placement,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error(`Unknown MCP tool: ${name}`);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const transport = new StdioServerTransport();
|
|
190
|
+
await server.connect(transport);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main().catch((err) => {
|
|
194
|
+
console.error('MCP Server Error:', err);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
});
|