zelune 1.4.0 → 1.5.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.
@@ -55,6 +55,14 @@ export interface PatchPayload {
55
55
  styles?: Record<string, StyleValue | null | undefined>;
56
56
  }
57
57
 
58
+ export interface ReorderPayload {
59
+ file: string;
60
+ astPath: string;
61
+ targetAstPath?: string;
62
+ placement?: 'before' | 'after';
63
+ direction?: 'up' | 'down';
64
+ }
65
+
58
66
  interface HistoryEntry {
59
67
  file: string;
60
68
  previousContent: string;
@@ -80,29 +88,188 @@ function withFileLock<T>(filePath: string, task: () => Promise<T>): Promise<T> {
80
88
  return next;
81
89
  }
82
90
 
91
+ const SCREEN_DIR_CANDIDATES = ['src/screens', 'screens', 'app', 'src/app'] as const;
92
+ const IGNORED_SCAN_DIRS = new Set([
93
+ 'node_modules',
94
+ '.git',
95
+ 'dist',
96
+ 'build',
97
+ '.next',
98
+ '.expo',
99
+ '.turbo',
100
+ 'ios',
101
+ 'android',
102
+ 'vendor',
103
+ 'coverage',
104
+ '.agents',
105
+ '.gemini',
106
+ ]);
107
+
108
+ function countScreenFilesInDirectory(screensDir: string, maxDepth = 4): number {
109
+ let count = 0;
110
+
111
+ function walk(curr: string, depth: number) {
112
+ if (depth > maxDepth) return;
113
+ let entries: fsSync.Dirent[] = [];
114
+ try {
115
+ entries = fsSync.readdirSync(curr, { withFileTypes: true });
116
+ } catch {
117
+ return;
118
+ }
119
+
120
+ for (const entry of entries) {
121
+ if (entry.name.startsWith('.')) continue;
122
+ if (entry.isDirectory()) {
123
+ if (
124
+ entry.name === 'node_modules' ||
125
+ entry.name === '__tests__' ||
126
+ entry.name === 'components'
127
+ ) {
128
+ continue;
129
+ }
130
+ walk(path.join(curr, entry.name), depth + 1);
131
+ } else if (entry.isFile()) {
132
+ const f = entry.name;
133
+ if (f.startsWith('_') || f.startsWith('+')) continue;
134
+ if (f === 'index.ts' || f === 'index.js') continue;
135
+ if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
136
+ if (f.endsWith('.tsx') || f.endsWith('.jsx')) {
137
+ count++;
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ walk(screensDir, 0);
144
+ return count;
145
+ }
146
+
147
+ function hasDirectScreenFiles(dirPath: string): { found: boolean; screenCount: number } {
148
+ for (const rel of SCREEN_DIR_CANDIDATES) {
149
+ const full = path.join(dirPath, rel);
150
+ try {
151
+ if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
152
+ const count = countScreenFilesInDirectory(full);
153
+ if (count > 0) {
154
+ return { found: true, screenCount: count };
155
+ }
156
+ }
157
+ } catch {
158
+ // Ignore unreadable directories
159
+ }
160
+ }
161
+ return { found: false, screenCount: 0 };
162
+ }
163
+
164
+ function scoreMobileCandidate(dirPath: string, screenCount: number): number {
165
+ let score = screenCount;
166
+ const folderName = path.basename(dirPath).toLowerCase();
167
+ if (/mobile|native|expo|app|client/.test(folderName)) {
168
+ score += 10;
169
+ }
170
+ if (fsSync.existsSync(path.join(dirPath, 'app.json')) || fsSync.existsSync(path.join(dirPath, 'eas.json'))) {
171
+ score += 25;
172
+ }
173
+ try {
174
+ const pkgPath = path.join(dirPath, 'package.json');
175
+ if (fsSync.existsSync(pkgPath)) {
176
+ const pkgRaw = fsSync.readFileSync(pkgPath, 'utf-8');
177
+ if (pkgRaw.includes('"react-native"') || pkgRaw.includes('"expo"')) {
178
+ score += 20;
179
+ }
180
+ }
181
+ } catch {
182
+ // Ignore malformed package.json
183
+ }
184
+ return score;
185
+ }
186
+
83
187
  /**
84
- * Resolves the active target project root (supports ANTIGRAVITY_TARGET_PROJECT for external projects).
188
+ * Automatically locates the React Native / Expo mobile project root even when invoked
189
+ * from a top-level monorepo or parent workspace folder (e.g., containing `mobile/` or `apps/mobile/`).
190
+ */
191
+ export function findMobileAppRoot(rawRoot: string): string {
192
+ const normalizedRoot = path.resolve(rawRoot);
193
+ if (!fsSync.existsSync(normalizedRoot)) {
194
+ return normalizedRoot;
195
+ }
196
+
197
+ // 1. Check if rawRoot itself directly contains React Native / Expo screens
198
+ const direct = hasDirectScreenFiles(normalizedRoot);
199
+ if (direct.found) {
200
+ return normalizedRoot;
201
+ }
202
+
203
+ // 2. Scan up to 2 levels of subdirectories for a nested mobile app
204
+ let bestCandidate: string | null = null;
205
+ let bestScore = -1;
206
+
207
+ function scanSubdirs(parentDir: string, depth: number) {
208
+ if (depth > 2) return;
209
+ let entries: fsSync.Dirent[] = [];
210
+ try {
211
+ entries = fsSync.readdirSync(parentDir, { withFileTypes: true });
212
+ } catch {
213
+ return;
214
+ }
215
+
216
+ for (const entry of entries) {
217
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
218
+ if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
219
+
220
+ const subPath = path.join(parentDir, entry.name);
221
+ const check = hasDirectScreenFiles(subPath);
222
+ if (check.found) {
223
+ const score = scoreMobileCandidate(subPath, check.screenCount);
224
+ if (score > bestScore) {
225
+ bestScore = score;
226
+ bestCandidate = subPath;
227
+ }
228
+ } else if (depth < 2) {
229
+ scanSubdirs(subPath, depth + 1);
230
+ }
231
+ }
232
+ }
233
+
234
+ scanSubdirs(normalizedRoot, 1);
235
+ return bestCandidate ?? normalizedRoot;
236
+ }
237
+
238
+ /**
239
+ * Resolves the active target project root (supports ANTIGRAVITY_TARGET_PROJECT and nested mobile folders).
85
240
  */
86
241
  export function getActiveProjectRoot(fallbackRoot: string): string {
87
242
  const envTarget = process.env.ANTIGRAVITY_TARGET_PROJECT;
88
- if (envTarget && fsSync.existsSync(envTarget)) {
89
- return path.resolve(envTarget);
90
- }
91
- return path.resolve(fallbackRoot);
243
+ const baseRoot =
244
+ envTarget && fsSync.existsSync(envTarget)
245
+ ? path.resolve(envTarget)
246
+ : path.resolve(fallbackRoot);
247
+ return findMobileAppRoot(baseRoot);
92
248
  }
93
249
 
94
250
  /**
95
251
  * Finds the active screens directory inside a React Native / Expo project.
96
- * Checks `src/screens`, `screens`, and `app` (Expo Router).
252
+ * Checks `src/screens`, `screens`, `app` (Expo Router), and `src/app`.
97
253
  */
98
254
  export function resolveScreensDirectory(projectRoot: string): {
99
255
  dirPath: string;
100
256
  relDir: string;
101
257
  } {
102
- const normalizedRoot = path.resolve(projectRoot);
103
- const candidates = ['src/screens', 'screens', 'app', 'src/app'];
258
+ const normalizedRoot = findMobileAppRoot(projectRoot);
104
259
 
105
- for (const rel of candidates) {
260
+ // Prefer a candidate directory that actually contains .tsx/.jsx screen files
261
+ for (const rel of SCREEN_DIR_CANDIDATES) {
262
+ const full = path.join(normalizedRoot, rel);
263
+ if (
264
+ fsSync.existsSync(full) &&
265
+ fsSync.statSync(full).isDirectory() &&
266
+ countScreenFilesInDirectory(full) > 0
267
+ ) {
268
+ return { dirPath: full, relDir: rel };
269
+ }
270
+ }
271
+
272
+ for (const rel of SCREEN_DIR_CANDIDATES) {
106
273
  const full = path.join(normalizedRoot, rel);
107
274
  if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
108
275
  return { dirPath: full, relDir: rel };
@@ -115,24 +282,36 @@ export function resolveScreensDirectory(projectRoot: string): {
115
282
  };
116
283
  }
117
284
 
285
+ const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
286
+
287
+ function safeRealpath(targetPath: string): string {
288
+ try {
289
+ return fsSync.realpathSync(targetPath);
290
+ } catch {
291
+ return path.resolve(targetPath);
292
+ }
293
+ }
294
+
118
295
  /**
119
296
  * Validates that a relative file path stays strictly inside the project's screens directory
120
- * (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`. Prevents path traversal attacks.
297
+ * (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`.
298
+ * Prevents lexical path traversal (`../`) and symbolic link escape attacks.
121
299
  */
122
300
  export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: string): string {
123
- const normalizedRoot = getActiveProjectRoot(workspaceRoot);
124
- const cleanRel = relativeFile.replace(/^[/\\]+/, '');
301
+ const normalizedRoot = safeRealpath(getActiveProjectRoot(workspaceRoot));
302
+ const cleanRel = String(relativeFile ?? '').replace(/^[/\\]+/, '');
125
303
  const resolved = path.resolve(normalizedRoot, cleanRel);
126
304
 
127
- const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) =>
128
- path.resolve(normalizedRoot, d)
129
- );
305
+ const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) => {
306
+ const candidate = path.resolve(normalizedRoot, d);
307
+ return fsSync.existsSync(candidate) ? safeRealpath(candidate) : candidate;
308
+ });
130
309
 
131
- const isInsideAllowed = allowedDirs.some(
310
+ const isLexicallyAllowed = allowedDirs.some(
132
311
  (dir) => resolved.startsWith(dir + path.sep) || resolved === dir
133
312
  );
134
313
 
135
- if (!isInsideAllowed) {
314
+ if (!isLexicallyAllowed) {
136
315
  throw new Error(
137
316
  `Security violation: File path "${relativeFile}" is outside allowed screen directories (src/screens, screens, app)`
138
317
  );
@@ -140,6 +319,27 @@ export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: strin
140
319
  if (!resolved.endsWith('.tsx') && !resolved.endsWith('.jsx')) {
141
320
  throw new Error(`Invalid file extension for "${relativeFile}". Only .tsx and .jsx are allowed.`);
142
321
  }
322
+
323
+ // Reject symbolic links or symlinked ancestor directories that escape the allowed screens directories
324
+ if (fsSync.existsSync(resolved)) {
325
+ const stat = fsSync.lstatSync(resolved);
326
+ if (stat.isSymbolicLink()) {
327
+ throw new Error(
328
+ `Security violation: Symbolic link "${relativeFile}" is not permitted inside screen directories`
329
+ );
330
+ }
331
+ const realTarget = safeRealpath(resolved);
332
+ const isRealInsideAllowed = allowedDirs.some(
333
+ (dir) => realTarget.startsWith(dir + path.sep) || realTarget === dir
334
+ );
335
+ if (!isRealInsideAllowed) {
336
+ throw new Error(
337
+ `Security violation: Canonical path for "${relativeFile}" resolves outside allowed screen directories`
338
+ );
339
+ }
340
+ return realTarget;
341
+ }
342
+
143
343
  return resolved;
144
344
  }
145
345
 
@@ -638,11 +838,25 @@ export async function inspectElement(
638
838
  };
639
839
  }
640
840
 
841
+ function createSafeJsxTextChild(text: string): t.JSXText | t.JSXExpressionContainer {
842
+ // @babel/generator prints JSXText.value verbatim without escaping '{', '}', '<', or '>'.
843
+ // Wrap any string containing JSX metacharacters, newlines, or leading/trailing whitespace
844
+ // inside a JSXExpressionContainer(StringLiteral) so Babel safely escapes it as {"..."}.
845
+ if (/[{}<>]|[\r\n]|^\s|\s$/.test(text)) {
846
+ return t.jsxExpressionContainer(t.stringLiteral(text));
847
+ }
848
+ return t.jsxText(text);
849
+ }
850
+
641
851
  function upsertObjectProperty(
642
852
  objExpr: t.ObjectExpression,
643
853
  key: string,
644
854
  val: StyleValue | null | undefined
645
855
  ) {
856
+ if (!key || FORBIDDEN_OBJECT_KEYS.has(key) || key.length > 80) {
857
+ throw new Error(`Security violation: Invalid style property name "${key}"`);
858
+ }
859
+
646
860
  const existingIndex = objExpr.properties.findIndex((prop) => {
647
861
  if (!t.isObjectProperty(prop)) return false;
648
862
  const k = t.isIdentifier(prop.key)
@@ -660,8 +874,22 @@ function upsertObjectProperty(
660
874
  return;
661
875
  }
662
876
 
877
+ if (typeof val !== 'number' && typeof val !== 'string') {
878
+ throw new Error(`Security violation: Style value for "${key}" must be a string or finite number`);
879
+ }
880
+ if (typeof val === 'number' && !Number.isFinite(val)) {
881
+ throw new Error(`Security violation: Style value for "${key}" must be a finite number`);
882
+ }
883
+ if (typeof val === 'string' && val.length > 500) {
884
+ throw new Error(`Security violation: Style value for "${key}" exceeds maximum length`);
885
+ }
886
+
663
887
  const valueNode =
664
- typeof val === 'number' ? t.numericLiteral(val) : t.stringLiteral(String(val));
888
+ typeof val === 'number'
889
+ ? val < 0
890
+ ? t.unaryExpression('-', t.numericLiteral(Math.abs(val)))
891
+ : t.numericLiteral(val)
892
+ : t.stringLiteral(val);
665
893
 
666
894
  if (existingIndex !== -1) {
667
895
  const existingProp = objExpr.properties[existingIndex] as t.ObjectProperty;
@@ -727,13 +955,16 @@ export async function patchElement(
727
955
  const opening = element.openingElement;
728
956
 
729
957
  if (payload.text !== undefined) {
958
+ if (typeof payload.text !== 'string') {
959
+ throw new Error('Invalid text payload: must be a string.');
960
+ }
730
961
  const textCheck = inspectJsxTextChildren(element);
731
962
  if (textCheck.hasDynamicText) {
732
963
  throw new Error(
733
964
  'Cannot overwrite dynamic JSX expression inside <Text>. Use "Ask Antigravity" for structural/expression edits.'
734
965
  );
735
966
  }
736
- element.children = [t.jsxText(payload.text)];
967
+ element.children = [createSafeJsxTextChild(payload.text)];
737
968
  }
738
969
 
739
970
  if (payload.styles && Object.keys(payload.styles).length > 0) {
@@ -807,6 +1038,184 @@ export async function patchElement(
807
1038
  });
808
1039
  }
809
1040
 
1041
+ /**
1042
+ * Surgically reorders a JSXElement among its Flexbox siblings in the `.tsx` AST.
1043
+ * Supports either explicit target sibling (`targetAstPath` + `placement: 'before' | 'after'`)
1044
+ * or relative movement (`direction: 'up' | 'down'`).
1045
+ */
1046
+ export async function reorderElement(
1047
+ workspaceRoot: string,
1048
+ payload: ReorderPayload
1049
+ ): Promise<{
1050
+ element: InspectedElement;
1051
+ undoAvailable: number;
1052
+ redoAvailable: number;
1053
+ }> {
1054
+ const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
1055
+
1056
+ return withFileLock(fullPath, async () => {
1057
+ const previousContent = await fs.readFile(fullPath, 'utf-8');
1058
+ const ast = parse(previousContent, {
1059
+ sourceType: 'module',
1060
+ plugins: ['typescript', 'jsx'],
1061
+ });
1062
+
1063
+ interface IndexedElementInfo {
1064
+ index: number;
1065
+ element: t.JSXElement;
1066
+ parent: t.JSXElement | t.JSXFragment | null;
1067
+ }
1068
+
1069
+ const byIndex = new Map<number, IndexedElementInfo>();
1070
+ const parentByElement = new Map<t.JSXElement, t.JSXElement | t.JSXFragment | null>();
1071
+ let currentIndex = 0;
1072
+
1073
+ traverse(ast, {
1074
+ JSXOpeningElement(nodePath) {
1075
+ const tagName = getJsxTagName(nodePath.node.name);
1076
+ if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1077
+ return;
1078
+ }
1079
+ const idx = currentIndex++;
1080
+ const jsxElPath = nodePath.parentPath;
1081
+ if (!jsxElPath.isJSXElement()) return;
1082
+
1083
+ const element = jsxElPath.node;
1084
+ const parentNode = jsxElPath.parentPath?.node;
1085
+ const parent =
1086
+ t.isJSXElement(parentNode) || t.isJSXFragment(parentNode) ? parentNode : null;
1087
+
1088
+ byIndex.set(idx, { index: idx, element, parent });
1089
+ parentByElement.set(element, parent);
1090
+ },
1091
+ });
1092
+
1093
+ const sourceIdx = Number.parseInt(payload.astPath, 10);
1094
+ const sourceInfo = byIndex.get(sourceIdx);
1095
+ if (!sourceInfo || !sourceInfo.parent) {
1096
+ throw new Error(`Element at astPath=${payload.astPath} cannot be reordered (no JSX parent container).`);
1097
+ }
1098
+
1099
+ const parentContainer = sourceInfo.parent;
1100
+ const sourceElement = sourceInfo.element;
1101
+
1102
+ const jsxSiblings = parentContainer.children.filter((c): c is t.JSXElement =>
1103
+ t.isJSXElement(c)
1104
+ );
1105
+ if (jsxSiblings.length < 2) {
1106
+ throw new Error('Element has no sibling elements inside its parent container to reorder with.');
1107
+ }
1108
+
1109
+ let targetSibling: t.JSXElement | null = null;
1110
+ let placement: 'before' | 'after' = payload.placement === 'after' ? 'after' : 'before';
1111
+
1112
+ if (payload.direction === 'up' || payload.direction === 'down') {
1113
+ const currPos = jsxSiblings.indexOf(sourceElement);
1114
+ if (currPos === -1) {
1115
+ throw new Error('Could not locate element inside parent children list.');
1116
+ }
1117
+ if (payload.direction === 'up') {
1118
+ if (currPos === 0) {
1119
+ throw new Error('Element is already first in its container.');
1120
+ }
1121
+ targetSibling = jsxSiblings[currPos - 1];
1122
+ placement = 'before';
1123
+ } else {
1124
+ if (currPos >= jsxSiblings.length - 1) {
1125
+ throw new Error('Element is already last in its container.');
1126
+ }
1127
+ targetSibling = jsxSiblings[currPos + 1];
1128
+ placement = 'after';
1129
+ }
1130
+ } else if (payload.targetAstPath !== undefined) {
1131
+ const targetIdx = Number.parseInt(payload.targetAstPath, 10);
1132
+ const rawTargetInfo = byIndex.get(targetIdx);
1133
+ if (!rawTargetInfo) {
1134
+ throw new Error(`Target sibling at astPath=${payload.targetAstPath} not found.`);
1135
+ }
1136
+
1137
+ // Walk up from rawTargetInfo.element until we find the direct child of parentContainer
1138
+ let candidate: t.JSXElement | null = rawTargetInfo.element;
1139
+ while (candidate && parentByElement.get(candidate) !== parentContainer) {
1140
+ const p = parentByElement.get(candidate);
1141
+ candidate = p && t.isJSXElement(p) ? p : null;
1142
+ }
1143
+ if (!candidate || candidate === sourceElement) {
1144
+ throw new Error('Target drop element is not a sibling inside the same Flexbox container.');
1145
+ }
1146
+ targetSibling = candidate;
1147
+ }
1148
+
1149
+ if (!targetSibling || targetSibling === sourceElement) {
1150
+ throw new Error('Invalid reorder target sibling.');
1151
+ }
1152
+
1153
+ // Rebuild parentContainer.children preserving non-whitespace nodes in the new order
1154
+ const meaningfulChildren = parentContainer.children.filter(
1155
+ (child) => !(t.isJSXText(child) && child.value.trim() === '')
1156
+ );
1157
+
1158
+ const withoutSource = meaningfulChildren.filter((child) => child !== sourceElement);
1159
+ const targetPos = withoutSource.indexOf(targetSibling);
1160
+ if (targetPos === -1) {
1161
+ throw new Error('Target sibling not found in parent children.');
1162
+ }
1163
+
1164
+ const insertIdx = placement === 'after' ? targetPos + 1 : targetPos;
1165
+ withoutSource.splice(insertIdx, 0, sourceElement);
1166
+ parentContainer.children = withoutSource;
1167
+
1168
+ // Re-index the mutated AST to find the updated pre-order astPath of sourceElement
1169
+ let newSourceAstPath = payload.astPath;
1170
+ let reindexCounter = 0;
1171
+ traverse(ast, {
1172
+ JSXOpeningElement(nodePath) {
1173
+ const tagName = getJsxTagName(nodePath.node.name);
1174
+ if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1175
+ return;
1176
+ }
1177
+ const idx = reindexCounter++;
1178
+ if (nodePath.parentPath.isJSXElement() && nodePath.parentPath.node === sourceElement) {
1179
+ newSourceAstPath = String(idx);
1180
+ }
1181
+ },
1182
+ });
1183
+
1184
+ const output = generate(ast, {
1185
+ retainLines: false,
1186
+ comments: true,
1187
+ jsescOption: { minimal: true },
1188
+ });
1189
+
1190
+ const nextContent = output.code + '\n';
1191
+ if (nextContent !== previousContent) {
1192
+ await fs.writeFile(fullPath, nextContent, 'utf-8');
1193
+ undoStack.push({
1194
+ file: payload.file,
1195
+ previousContent,
1196
+ nextContent,
1197
+ description: `Reordered ${getJsxTagName(sourceElement.openingElement.name)} in ${path.basename(payload.file)}`,
1198
+ timestamp: Date.now(),
1199
+ });
1200
+ if (undoStack.length > MAX_HISTORY) {
1201
+ undoStack.shift();
1202
+ }
1203
+ redoStack.length = 0;
1204
+ }
1205
+
1206
+ const updatedElement = await inspectElement(workspaceRoot, {
1207
+ file: payload.file,
1208
+ astPath: newSourceAstPath,
1209
+ });
1210
+
1211
+ return {
1212
+ element: updatedElement,
1213
+ undoAvailable: undoStack.length,
1214
+ redoAvailable: redoStack.length,
1215
+ };
1216
+ });
1217
+ }
1218
+
810
1219
  export async function undoLastPatch(workspaceRoot: string): Promise<{
811
1220
  restoredFile: string | null;
812
1221
  description: string | null;