@magentrix-corp/magentrix-cli 1.3.15 → 1.3.17
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/LICENSE +25 -25
- package/README.md +1166 -1166
- package/actions/autopublish.old.js +293 -293
- package/actions/config.js +182 -182
- package/actions/create.js +466 -466
- package/actions/help.js +164 -164
- package/actions/iris/buildStage.js +874 -874
- package/actions/iris/delete.js +256 -256
- package/actions/iris/dev.js +391 -391
- package/actions/iris/index.js +6 -6
- package/actions/iris/link.js +375 -375
- package/actions/iris/recover.js +268 -268
- package/actions/main.js +80 -80
- package/actions/publish.js +1420 -1420
- package/actions/pull.js +684 -684
- package/actions/setup.js +148 -148
- package/actions/status.js +17 -17
- package/actions/update.js +248 -248
- package/bin/magentrix.js +393 -393
- package/package.json +55 -55
- package/utils/assetPaths.js +158 -158
- package/utils/autopublishLock.js +77 -77
- package/utils/cacher.js +206 -206
- package/utils/cli/checkInstanceUrl.js +76 -45
- package/utils/cli/helpers/compare.js +282 -282
- package/utils/cli/helpers/ensureApiKey.js +63 -63
- package/utils/cli/helpers/ensureCredentials.js +68 -68
- package/utils/cli/helpers/ensureInstanceUrl.js +75 -75
- package/utils/cli/writeRecords.js +262 -262
- package/utils/compare.js +135 -135
- package/utils/compress.js +17 -17
- package/utils/config.js +527 -527
- package/utils/debug.js +144 -144
- package/utils/diagnostics/testPublishLogic.js +96 -96
- package/utils/diff.js +49 -49
- package/utils/downloadAssets.js +291 -291
- package/utils/filetag.js +115 -115
- package/utils/hash.js +14 -14
- package/utils/iris/backup.js +411 -411
- package/utils/iris/builder.js +541 -541
- package/utils/iris/config-reader.js +664 -664
- package/utils/iris/deleteHelper.js +150 -150
- package/utils/iris/errors.js +537 -537
- package/utils/iris/linker.js +601 -601
- package/utils/iris/lock.js +360 -360
- package/utils/iris/validation.js +360 -360
- package/utils/iris/validator.js +281 -281
- package/utils/iris/zipper.js +248 -248
- package/utils/logger.js +291 -291
- package/utils/magentrix/api/assets.js +220 -220
- package/utils/magentrix/api/auth.js +107 -107
- package/utils/magentrix/api/createEntity.js +61 -61
- package/utils/magentrix/api/deleteEntity.js +55 -55
- package/utils/magentrix/api/iris.js +251 -251
- package/utils/magentrix/api/meqlQuery.js +36 -36
- package/utils/magentrix/api/retrieveEntity.js +86 -86
- package/utils/magentrix/api/updateEntity.js +66 -66
- package/utils/magentrix/fetch.js +168 -168
- package/utils/merge.js +22 -22
- package/utils/permissionError.js +70 -70
- package/utils/preferences.js +40 -40
- package/utils/progress.js +469 -469
- package/utils/spinner.js +43 -43
- package/utils/template.js +52 -52
- package/utils/updateFileBase.js +121 -121
- package/utils/workspaces.js +108 -108
- package/vars/config.js +11 -11
- package/vars/global.js +50 -50
package/actions/publish.js
CHANGED
|
@@ -1,1420 +1,1420 @@
|
|
|
1
|
-
import { walkFiles } from "../utils/cacher.js";
|
|
2
|
-
import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
|
|
3
|
-
import Config from "../utils/config.js";
|
|
4
|
-
import { withSpinner } from "../utils/spinner.js";
|
|
5
|
-
import { ProgressTracker } from "../utils/progress.js";
|
|
6
|
-
import fs from "fs";
|
|
7
|
-
import path from "path";
|
|
8
|
-
import chalk from "chalk";
|
|
9
|
-
import {
|
|
10
|
-
ENTITY_FIELD_MAP,
|
|
11
|
-
ENTITY_TYPE_MAP,
|
|
12
|
-
EXPORT_ROOT,
|
|
13
|
-
TYPE_DIR_MAP,
|
|
14
|
-
IRIS_APPS_DIR,
|
|
15
|
-
ALLOWED_SRC_DIRS,
|
|
16
|
-
} from "../vars/global.js";
|
|
17
|
-
import { getFileTag, setFileTag } from "../utils/filetag.js";
|
|
18
|
-
import { sha256 } from "../utils/hash.js";
|
|
19
|
-
import { createEntity } from "../utils/magentrix/api/createEntity.js";
|
|
20
|
-
import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
|
|
21
|
-
import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
|
|
22
|
-
import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
|
|
23
|
-
import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
|
|
24
|
-
import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
|
|
25
|
-
import { publishApp } from "../utils/magentrix/api/iris.js";
|
|
26
|
-
import { createIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
|
|
27
|
-
import { validateIrisAppFolder } from "../utils/iris/validator.js";
|
|
28
|
-
import { getLinkedProjects } from "../utils/iris/linker.js";
|
|
29
|
-
import { deleteIrisAppFromServer } from "../utils/iris/deleteHelper.js";
|
|
30
|
-
|
|
31
|
-
const config = new Config();
|
|
32
|
-
|
|
33
|
-
/* ==================== CONFIGURATION ==================== */
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Set to true to process all operations sequentially (one at a time).
|
|
37
|
-
* Set to false to process operations in parallel with intelligent grouping.
|
|
38
|
-
*
|
|
39
|
-
* Sequential mode is slower but avoids any potential race conditions or
|
|
40
|
-
* server-side rate limiting issues.
|
|
41
|
-
*/
|
|
42
|
-
const USE_SEQUENTIAL_PROCESSING = true;
|
|
43
|
-
|
|
44
|
-
/* ==================== UTILITY FUNCTIONS ==================== */
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Infers Magentrix entity metadata from a file path by walking up the directory tree.
|
|
48
|
-
* @param {string} filePath - The file path to analyze
|
|
49
|
-
* @returns {Object} { type, entity, contentField } for Magentrix API
|
|
50
|
-
*/
|
|
51
|
-
const inferMeta = (filePath) => {
|
|
52
|
-
let currentPath = path.dirname(filePath);
|
|
53
|
-
const exportRootAbs = path.resolve(EXPORT_ROOT);
|
|
54
|
-
|
|
55
|
-
while (currentPath.startsWith(exportRootAbs)) {
|
|
56
|
-
const folderName = path.basename(currentPath);
|
|
57
|
-
const type = Object.keys(TYPE_DIR_MAP).find(
|
|
58
|
-
(k) => TYPE_DIR_MAP[k].directory === folderName
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
if (type) {
|
|
62
|
-
return {
|
|
63
|
-
type,
|
|
64
|
-
entity: ENTITY_TYPE_MAP[type],
|
|
65
|
-
contentField: ENTITY_FIELD_MAP[type],
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const parentPath = path.dirname(currentPath);
|
|
70
|
-
if (parentPath === currentPath) break; // Reached root
|
|
71
|
-
currentPath = parentPath;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return { type: undefined, entity: undefined, contentField: undefined };
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Safely reads file content and computes hash.
|
|
79
|
-
* @param {string} filePath - Path to the file
|
|
80
|
-
* @returns {Object|null} { content, hash } or null on error
|
|
81
|
-
*/
|
|
82
|
-
const readFileSafe = (filePath) => {
|
|
83
|
-
try {
|
|
84
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
85
|
-
return { content, hash: sha256(content) };
|
|
86
|
-
} catch (err) {
|
|
87
|
-
console.error(chalk.red.bold("Error:") + " Unable to read file " + chalk.white.bold(filePath));
|
|
88
|
-
console.error(chalk.gray("→ Please verify the file exists and you have read permissions."));
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Formats multiline error messages with proper indentation.
|
|
95
|
-
*/
|
|
96
|
-
const formatMultilineError = (err) => {
|
|
97
|
-
const lines = String(err).split(/\r?\n/);
|
|
98
|
-
return lines
|
|
99
|
-
.map((line, i) => {
|
|
100
|
-
const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' ';
|
|
101
|
-
return prefix + chalk.whiteBright(line);
|
|
102
|
-
})
|
|
103
|
-
.join('\n');
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Recursively walks a directory and returns all subdirectory paths.
|
|
108
|
-
*/
|
|
109
|
-
const walkFolders = (dir) => {
|
|
110
|
-
if (!fs.existsSync(dir)) return [];
|
|
111
|
-
|
|
112
|
-
const folders = [];
|
|
113
|
-
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
114
|
-
|
|
115
|
-
for (const item of items) {
|
|
116
|
-
if (item.isDirectory()) {
|
|
117
|
-
const fullPath = path.join(dir, item.name);
|
|
118
|
-
folders.push(fullPath);
|
|
119
|
-
folders.push(...walkFolders(fullPath));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return folders;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Gets display name for an action in log messages.
|
|
128
|
-
*/
|
|
129
|
-
const getActionDisplayName = (action) => {
|
|
130
|
-
// Iris app actions
|
|
131
|
-
if (action.slug) return `${action.appName || action.slug} (${action.slug})`;
|
|
132
|
-
if (action.folderName) return action.folderName;
|
|
133
|
-
if (action.names) return action.names.join(", ");
|
|
134
|
-
if (action.type) return action.type;
|
|
135
|
-
if (action.filePath) return path.basename(action.filePath);
|
|
136
|
-
return '';
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/* ==================== ACTION HANDLERS ==================== */
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Handles CREATE action for code entities (ActiveClass/ActivePage).
|
|
143
|
-
*/
|
|
144
|
-
const handleCreateAction = async (instanceUrl, apiKey, action) => {
|
|
145
|
-
const data = {
|
|
146
|
-
Name: path.basename(action.filePath, path.extname(action.filePath)),
|
|
147
|
-
Type: action.type,
|
|
148
|
-
...action.fields
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const result = await createEntity(instanceUrl, apiKey, action.entity, data);
|
|
152
|
-
return { recordId: result.recordId || result.id };
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Handles UPDATE action for code entities (ActiveClass/ActivePage).
|
|
157
|
-
*/
|
|
158
|
-
const handleUpdateAction = async (instanceUrl, apiKey, action) => {
|
|
159
|
-
const data = { ...action.fields };
|
|
160
|
-
|
|
161
|
-
if (action.renamed) {
|
|
162
|
-
data.Name = path.basename(action.filePath, path.extname(action.filePath));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
|
|
166
|
-
return { recordId: action.recordId };
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Handles DELETE action for code entities (ActiveClass/ActivePage).
|
|
171
|
-
*/
|
|
172
|
-
const handleDeleteAction = async (instanceUrl, apiKey, action) => {
|
|
173
|
-
return await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Handles CREATE_STATIC_ASSET action.
|
|
178
|
-
*/
|
|
179
|
-
const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
|
|
180
|
-
const response = await uploadAsset(instanceUrl, apiKey, `/${action.folder}`, [action.filePath]);
|
|
181
|
-
if (response?.error) throw new Error(response.error);
|
|
182
|
-
return response;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Handles DELETE_STATIC_ASSET action.
|
|
187
|
-
*/
|
|
188
|
-
const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
|
|
189
|
-
try {
|
|
190
|
-
const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names);
|
|
191
|
-
return response;
|
|
192
|
-
} catch (error) {
|
|
193
|
-
// Check if this is a "not found" error
|
|
194
|
-
const errorMessage = error?.message || String(error);
|
|
195
|
-
const errorLower = errorMessage.toLowerCase();
|
|
196
|
-
const isNotFound = errorLower.includes('404') ||
|
|
197
|
-
errorLower.includes('not found') ||
|
|
198
|
-
errorLower.includes('item not found');
|
|
199
|
-
|
|
200
|
-
if (isNotFound) {
|
|
201
|
-
// Clean up base.json since file doesn't exist on server
|
|
202
|
-
// Use the original base.json key if available (avoids path format mismatches)
|
|
203
|
-
if (action.baseKey) {
|
|
204
|
-
removeFromBase(action.baseKey);
|
|
205
|
-
} else {
|
|
206
|
-
for (const name of action.names) {
|
|
207
|
-
const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
|
|
208
|
-
removeFromBase(filePath);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return { cleanedFromCache: true };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Other errors should still fail
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Handles CREATE_FOLDER action.
|
|
221
|
-
*/
|
|
222
|
-
const handleCreateFolderAction = async (instanceUrl, apiKey, action) => {
|
|
223
|
-
try {
|
|
224
|
-
const response = await createFolder(instanceUrl, apiKey, action.parentPath, action.folderName);
|
|
225
|
-
return response;
|
|
226
|
-
} catch (error) {
|
|
227
|
-
// Check if folder already exists (likely created by file upload)
|
|
228
|
-
const errorMessage = error?.message || String(error);
|
|
229
|
-
const errorLower = errorMessage.toLowerCase();
|
|
230
|
-
const alreadyExists = errorLower.includes('already exists') ||
|
|
231
|
-
errorLower.includes('folder exists') ||
|
|
232
|
-
errorLower.includes('duplicate');
|
|
233
|
-
|
|
234
|
-
if (alreadyExists) {
|
|
235
|
-
// Folder already exists, update cache and treat as success
|
|
236
|
-
return { alreadyExisted: true };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Other errors should still fail
|
|
240
|
-
throw error;
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Handles DELETE_FOLDER action.
|
|
246
|
-
*/
|
|
247
|
-
const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
|
|
248
|
-
try {
|
|
249
|
-
const response = await deleteAsset(instanceUrl, apiKey, action.parentPath, [action.folderName]);
|
|
250
|
-
return response;
|
|
251
|
-
} catch (error) {
|
|
252
|
-
// Check if this is a "not found" error
|
|
253
|
-
const errorMessage = error?.message || String(error);
|
|
254
|
-
const errorLower = errorMessage.toLowerCase();
|
|
255
|
-
const isNotFound = errorLower.includes('404') ||
|
|
256
|
-
errorLower.includes('not found') ||
|
|
257
|
-
errorLower.includes('item not found');
|
|
258
|
-
|
|
259
|
-
if (isNotFound) {
|
|
260
|
-
// Clean up base.json since folder doesn't exist on server
|
|
261
|
-
// Use original base.json key if available (avoids path format mismatches)
|
|
262
|
-
removeFromBase(action.baseKey || action.folderPath);
|
|
263
|
-
|
|
264
|
-
// Also remove all files and subfolders inside this folder from base
|
|
265
|
-
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
266
|
-
const cachedResults = hits?.[0]?.value || {};
|
|
267
|
-
|
|
268
|
-
for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
|
|
269
|
-
// Check all possible path fields
|
|
270
|
-
const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
|
|
271
|
-
if (entryPath && typeof entryPath === 'string') {
|
|
272
|
-
const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
|
|
273
|
-
const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
|
|
274
|
-
|
|
275
|
-
// Check if this entry is inside the deleted folder
|
|
276
|
-
if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
|
|
277
|
-
normalizedEntryPath === normalizedFolderPath) {
|
|
278
|
-
removeFromBase(recordId);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return { cleanedFromCache: true };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Other errors should still fail
|
|
287
|
-
throw error;
|
|
288
|
-
}
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Handles CREATE_IRIS_APP or UPDATE_IRIS_APP action.
|
|
293
|
-
*/
|
|
294
|
-
const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
|
|
295
|
-
// Create zip from the app folder
|
|
296
|
-
const zipBuffer = await createIrisZip(action.appPath, action.slug);
|
|
297
|
-
|
|
298
|
-
// For updates, don't send app-name/description/icon to avoid triggering
|
|
299
|
-
// metadata modification that may require permissions the user doesn't have.
|
|
300
|
-
// The server already has the correct metadata from the original create.
|
|
301
|
-
// For creates, send all metadata so the app is properly registered.
|
|
302
|
-
const isUpdate = action.action === 'update_iris_app';
|
|
303
|
-
|
|
304
|
-
const response = await publishApp(
|
|
305
|
-
instanceUrl,
|
|
306
|
-
apiKey,
|
|
307
|
-
zipBuffer,
|
|
308
|
-
`${action.slug}.zip`,
|
|
309
|
-
isUpdate ? null : action.appName,
|
|
310
|
-
isUpdate ? {} : {
|
|
311
|
-
appDescription: action.appDescription,
|
|
312
|
-
appIconId: action.appIconId
|
|
313
|
-
}
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
return response;
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Handles DELETE_IRIS_APP action.
|
|
321
|
-
*/
|
|
322
|
-
const handleDeleteIrisAppAction = async (instanceUrl, apiKey, action) => {
|
|
323
|
-
// Use shared delete utility for consistency
|
|
324
|
-
return await deleteIrisAppFromServer(instanceUrl, apiKey, action.slug, {
|
|
325
|
-
updateCache: true // Cache will be updated by the utility
|
|
326
|
-
});
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Synchronizes class name in content with filename, or vice versa.
|
|
331
|
-
*/
|
|
332
|
-
const syncClassAndFileNames = (action, recordId) => {
|
|
333
|
-
// Only for ActiveClass
|
|
334
|
-
if (action.entity !== 'ActiveClass') return;
|
|
335
|
-
|
|
336
|
-
const filePath = action.filePath;
|
|
337
|
-
// If file was deleted or doesn't exist, skip
|
|
338
|
-
if (!fs.existsSync(filePath)) return;
|
|
339
|
-
|
|
340
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
341
|
-
const filename = path.basename(filePath, path.extname(filePath));
|
|
342
|
-
|
|
343
|
-
// Regex to find class/interface/enum name
|
|
344
|
-
// Matches: public class Name, public interface Name, public enum Name
|
|
345
|
-
// We assume standard formatting
|
|
346
|
-
const classRegex = /public\s+(?:class|interface|enum)\s+(\w+)/;
|
|
347
|
-
const match = content.match(classRegex);
|
|
348
|
-
|
|
349
|
-
if (match) {
|
|
350
|
-
const classNameInContent = match[1];
|
|
351
|
-
|
|
352
|
-
if (classNameInContent !== filename) {
|
|
353
|
-
// Mismatch detected
|
|
354
|
-
|
|
355
|
-
// Case 1: File was renamed (action.renamed is true) -> Update content
|
|
356
|
-
if (action.renamed) {
|
|
357
|
-
const newContent = content.replace(classRegex, (fullMatch, name) => {
|
|
358
|
-
return fullMatch.replace(name, filename);
|
|
359
|
-
});
|
|
360
|
-
fs.writeFileSync(filePath, newContent);
|
|
361
|
-
console.log(chalk.cyan(` ↻ Updated class name in file to: ${filename}`));
|
|
362
|
-
|
|
363
|
-
// Update cache with new content hash
|
|
364
|
-
updateBase(filePath, { Id: recordId, Type: 'ActiveClass' });
|
|
365
|
-
}
|
|
366
|
-
// Case 2: Content was updated (action.renamed is false) -> Rename file
|
|
367
|
-
else {
|
|
368
|
-
// Rename file to match class name
|
|
369
|
-
const dir = path.dirname(filePath);
|
|
370
|
-
const ext = path.extname(filePath);
|
|
371
|
-
const newFilename = `${classNameInContent}${ext}`;
|
|
372
|
-
const newFilePath = path.join(dir, newFilename);
|
|
373
|
-
|
|
374
|
-
if (fs.existsSync(newFilePath)) {
|
|
375
|
-
console.warn(chalk.yellow(` ⚠️ Cannot rename ${filename} to ${classNameInContent}: File already exists.`));
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
fs.renameSync(filePath, newFilePath);
|
|
381
|
-
console.log(chalk.cyan(` ↻ Renamed file to match class: ${newFilename}`));
|
|
382
|
-
|
|
383
|
-
// Update cache: update the entry for this recordId to point to new path
|
|
384
|
-
updateBase(newFilePath, { Id: recordId, Type: 'ActiveClass' }, newFilePath);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
console.warn(chalk.yellow(` ⚠️ Failed to rename file: ${err.message}`));
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Updates cache after successful operations.
|
|
395
|
-
*/
|
|
396
|
-
const updateCacheAfterSuccess = async (action, operationResult) => {
|
|
397
|
-
try {
|
|
398
|
-
// Sync class names/filenames if needed (Bug 2 Fix)
|
|
399
|
-
if (action.action === 'update' && operationResult?.recordId) {
|
|
400
|
-
syncClassAndFileNames(action, operationResult.recordId);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
switch (action.action) {
|
|
404
|
-
case "create": {
|
|
405
|
-
const createSnapshot = action.fields && Object.values(action.fields)[0]
|
|
406
|
-
? { content: Object.values(action.fields)[0], hash: action.contentHash }
|
|
407
|
-
: null;
|
|
408
|
-
await setFileTag(action.filePath, operationResult.recordId);
|
|
409
|
-
updateBase(
|
|
410
|
-
action.filePath,
|
|
411
|
-
{ Id: operationResult.recordId, Type: action.type },
|
|
412
|
-
'',
|
|
413
|
-
createSnapshot
|
|
414
|
-
);
|
|
415
|
-
break;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
case "update": {
|
|
419
|
-
const updateSnapshot = action.fields && Object.values(action.fields)[0]
|
|
420
|
-
? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
|
|
421
|
-
: null;
|
|
422
|
-
const type = Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity);
|
|
423
|
-
updateBase(
|
|
424
|
-
action.filePath,
|
|
425
|
-
{ Id: action.recordId, Type: type },
|
|
426
|
-
'',
|
|
427
|
-
updateSnapshot
|
|
428
|
-
);
|
|
429
|
-
break;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
case "delete":
|
|
433
|
-
removeFromBase(action.recordId);
|
|
434
|
-
break;
|
|
435
|
-
|
|
436
|
-
case "delete_static_asset":
|
|
437
|
-
// Skip if already cleaned from cache during 404 handling
|
|
438
|
-
if (!operationResult?.cleanedFromCache) {
|
|
439
|
-
// Use the original base.json key if available (avoids path format mismatches)
|
|
440
|
-
if (action.baseKey) {
|
|
441
|
-
removeFromBase(action.baseKey);
|
|
442
|
-
} else {
|
|
443
|
-
for (const name of action.names) {
|
|
444
|
-
const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
|
|
445
|
-
removeFromBase(filePath);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
break;
|
|
450
|
-
|
|
451
|
-
case "create_static_asset":
|
|
452
|
-
updateBase(action.filePath, { Id: action.filePath, Type: "File" });
|
|
453
|
-
break;
|
|
454
|
-
|
|
455
|
-
case "create_folder":
|
|
456
|
-
updateBase(action.folderPath, { Id: action.folderPath, Type: "Folder" });
|
|
457
|
-
break;
|
|
458
|
-
|
|
459
|
-
case "delete_folder": {
|
|
460
|
-
// Skip if already cleaned from cache during 404 handling
|
|
461
|
-
if (!operationResult?.cleanedFromCache) {
|
|
462
|
-
// Remove the folder itself from base using the original base.json key
|
|
463
|
-
removeFromBase(action.baseKey || action.folderPath);
|
|
464
|
-
|
|
465
|
-
// Also remove all files and subfolders inside this folder from base
|
|
466
|
-
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
467
|
-
const cachedResults = hits?.[0]?.value || {};
|
|
468
|
-
|
|
469
|
-
for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
|
|
470
|
-
// Check all possible path fields
|
|
471
|
-
const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
|
|
472
|
-
if (entryPath && typeof entryPath === 'string') {
|
|
473
|
-
const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
|
|
474
|
-
const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
|
|
475
|
-
|
|
476
|
-
// Check if this entry is inside the deleted folder
|
|
477
|
-
if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
|
|
478
|
-
normalizedEntryPath === normalizedFolderPath) {
|
|
479
|
-
removeFromBase(recordId);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
case "create_iris_app":
|
|
488
|
-
case "update_iris_app": {
|
|
489
|
-
// Update base.json with new/updated Iris app info including content hash
|
|
490
|
-
updateBase(action.appPath, {
|
|
491
|
-
Id: `iris-app:${action.slug}`,
|
|
492
|
-
Type: 'IrisApp',
|
|
493
|
-
folderName: action.slug,
|
|
494
|
-
appName: action.appName,
|
|
495
|
-
modifiedOn: new Date().toISOString(),
|
|
496
|
-
contentHash: action.contentHash // Store hash for change detection
|
|
497
|
-
}, action.appPath);
|
|
498
|
-
break;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
case "delete_iris_app": {
|
|
502
|
-
// Skip if already cleaned from cache during 404 handling
|
|
503
|
-
if (!operationResult?.cleanedFromCache) {
|
|
504
|
-
removeFromBase(`iris-app:${action.slug}`);
|
|
505
|
-
}
|
|
506
|
-
break;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
} catch (error) {
|
|
510
|
-
console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
/* ==================== NETWORK REQUEST HANDLER ==================== */
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Groups actions by resource ID to detect conflicts and sequence operations.
|
|
518
|
-
* @returns {Object} { byResource: Map<recordId, actions[]>, assets: actions[] }
|
|
519
|
-
*/
|
|
520
|
-
const groupActionsByResource = (actionQueue) => {
|
|
521
|
-
const byResource = new Map(); // recordId -> actions[]
|
|
522
|
-
const assets = []; // Asset operations (can run in parallel)
|
|
523
|
-
|
|
524
|
-
for (let i = 0; i < actionQueue.length; i++) {
|
|
525
|
-
const action = { ...actionQueue[i], originalIndex: i };
|
|
526
|
-
|
|
527
|
-
// Asset and Iris app operations don't need sequencing
|
|
528
|
-
if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder', 'create_iris_app', 'update_iris_app', 'delete_iris_app'].includes(action.action)) {
|
|
529
|
-
assets.push(action);
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Code entity operations - group by recordId
|
|
534
|
-
const resourceId = action.recordId;
|
|
535
|
-
if (!resourceId) {
|
|
536
|
-
// Create actions without recordId yet - treat as unique resource
|
|
537
|
-
assets.push(action);
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (!byResource.has(resourceId)) {
|
|
542
|
-
byResource.set(resourceId, []);
|
|
543
|
-
}
|
|
544
|
-
byResource.get(resourceId).push(action);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return { byResource, assets };
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Resolves conflicts for a single resource and returns actions to execute in sequence.
|
|
552
|
-
* Rules:
|
|
553
|
-
* - If DELETE exists, drop all other actions (delete wins)
|
|
554
|
-
* - If multiple UPDATEs exist, keep them in order (will execute sequentially)
|
|
555
|
-
*/
|
|
556
|
-
const resolveResourceConflicts = (actions) => {
|
|
557
|
-
// Check if there's a delete action
|
|
558
|
-
const hasDelete = actions.some(a => a.action === 'delete');
|
|
559
|
-
|
|
560
|
-
if (hasDelete) {
|
|
561
|
-
// Delete wins - drop all other actions and only keep the delete
|
|
562
|
-
return actions.filter(a => a.action === 'delete');
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// No delete - return all actions (they'll execute sequentially)
|
|
566
|
-
return actions;
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Executes actions for a single resource sequentially.
|
|
571
|
-
*/
|
|
572
|
-
const executeResourceActions = async (instanceUrl, token, actions) => {
|
|
573
|
-
const results = [];
|
|
574
|
-
|
|
575
|
-
for (const action of actions) {
|
|
576
|
-
try {
|
|
577
|
-
let result;
|
|
578
|
-
switch (action.action) {
|
|
579
|
-
case "create":
|
|
580
|
-
result = await handleCreateAction(instanceUrl, token, action);
|
|
581
|
-
break;
|
|
582
|
-
case "update":
|
|
583
|
-
result = await handleUpdateAction(instanceUrl, token, action);
|
|
584
|
-
break;
|
|
585
|
-
case "delete":
|
|
586
|
-
result = await handleDeleteAction(instanceUrl, token, action);
|
|
587
|
-
break;
|
|
588
|
-
default:
|
|
589
|
-
throw new Error(`Unknown action: ${action.action}`);
|
|
590
|
-
}
|
|
591
|
-
results.push({ index: action.originalIndex, action, result, success: true });
|
|
592
|
-
} catch (error) {
|
|
593
|
-
results.push({ index: action.originalIndex, action, error: error.message, success: false });
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
return results;
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Executes a single action and returns the result.
|
|
602
|
-
*/
|
|
603
|
-
const executeAction = async (instanceUrl, token, action) => {
|
|
604
|
-
let result;
|
|
605
|
-
switch (action.action) {
|
|
606
|
-
case "create":
|
|
607
|
-
result = await handleCreateAction(instanceUrl, token, action);
|
|
608
|
-
break;
|
|
609
|
-
case "update":
|
|
610
|
-
result = await handleUpdateAction(instanceUrl, token, action);
|
|
611
|
-
break;
|
|
612
|
-
case "delete":
|
|
613
|
-
result = await handleDeleteAction(instanceUrl, token, action);
|
|
614
|
-
break;
|
|
615
|
-
case "create_static_asset":
|
|
616
|
-
result = await handleCreateStaticAssetAction(instanceUrl, token, action);
|
|
617
|
-
break;
|
|
618
|
-
case "delete_static_asset":
|
|
619
|
-
result = await handleDeleteStaticAssetAction(instanceUrl, token, action);
|
|
620
|
-
break;
|
|
621
|
-
case "create_folder":
|
|
622
|
-
result = await handleCreateFolderAction(instanceUrl, token, action);
|
|
623
|
-
break;
|
|
624
|
-
case "delete_folder":
|
|
625
|
-
result = await handleDeleteFolderAction(instanceUrl, token, action);
|
|
626
|
-
break;
|
|
627
|
-
case "create_iris_app":
|
|
628
|
-
case "update_iris_app":
|
|
629
|
-
result = await handlePublishIrisAppAction(instanceUrl, token, action);
|
|
630
|
-
break;
|
|
631
|
-
case "delete_iris_app":
|
|
632
|
-
result = await handleDeleteIrisAppAction(instanceUrl, token, action);
|
|
633
|
-
break;
|
|
634
|
-
default:
|
|
635
|
-
throw new Error(`Unknown action: ${action.action}`);
|
|
636
|
-
}
|
|
637
|
-
return result;
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Executes all actions sequentially (one at a time) with progress messages.
|
|
642
|
-
*/
|
|
643
|
-
const performNetworkRequestSequential = async (actionQueue) => {
|
|
644
|
-
const { instanceUrl, token } = await ensureValidCredentials();
|
|
645
|
-
|
|
646
|
-
console.log(chalk.blue(`\n🔄 Sequential processing mode (${actionQueue.length} operations)\n`));
|
|
647
|
-
|
|
648
|
-
const results = [];
|
|
649
|
-
let successCount = 0;
|
|
650
|
-
let errorCount = 0;
|
|
651
|
-
|
|
652
|
-
for (let i = 0; i < actionQueue.length; i++) {
|
|
653
|
-
const action = { ...actionQueue[i], originalIndex: i };
|
|
654
|
-
const displayName = getActionDisplayName(action);
|
|
655
|
-
|
|
656
|
-
// Show progress message
|
|
657
|
-
console.log(chalk.gray(`[${i + 1}/${actionQueue.length}] Processing ${action.action.toUpperCase()} ${displayName}...`));
|
|
658
|
-
|
|
659
|
-
try {
|
|
660
|
-
const result = await executeAction(instanceUrl, token.value, action);
|
|
661
|
-
results.push({ index: i, action, result, success: true });
|
|
662
|
-
|
|
663
|
-
successCount++;
|
|
664
|
-
console.log(
|
|
665
|
-
chalk.green(`✓ [${i + 1}]`) +
|
|
666
|
-
` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
|
|
667
|
-
(result?.recordId ? chalk.magenta(result.recordId) : "")
|
|
668
|
-
);
|
|
669
|
-
await updateCacheAfterSuccess(action, result);
|
|
670
|
-
} catch (error) {
|
|
671
|
-
results.push({ index: i, action, error: error.message, success: false });
|
|
672
|
-
|
|
673
|
-
errorCount++;
|
|
674
|
-
console.log();
|
|
675
|
-
console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
|
|
676
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
677
|
-
const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
|
|
678
|
-
console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${actionPath}):`));
|
|
679
|
-
console.log(formatMultilineError(error.message));
|
|
680
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
console.log(); // Add spacing between operations
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Summary
|
|
687
|
-
console.log(chalk.blue("--- Publish Summary ---"));
|
|
688
|
-
console.log(chalk.green(`✓ Successful: ${successCount}`));
|
|
689
|
-
if (errorCount > 0) {
|
|
690
|
-
console.log(chalk.red(`✗ Failed: ${errorCount}`));
|
|
691
|
-
} else {
|
|
692
|
-
console.log(chalk.green("All operations completed successfully! 🎉"));
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Executes all actions in the queue with proper sequencing and conflict resolution (parallel mode).
|
|
698
|
-
*/
|
|
699
|
-
const performNetworkRequestParallel = async (actionQueue) => {
|
|
700
|
-
const { instanceUrl, token } = await ensureValidCredentials();
|
|
701
|
-
|
|
702
|
-
// Group actions by resource
|
|
703
|
-
const { byResource, assets } = groupActionsByResource(actionQueue);
|
|
704
|
-
|
|
705
|
-
// Resolve conflicts for each resource
|
|
706
|
-
const sequencedCodeActions = [];
|
|
707
|
-
const droppedActions = [];
|
|
708
|
-
|
|
709
|
-
for (const [resourceId, actions] of byResource.entries()) {
|
|
710
|
-
const resolved = resolveResourceConflicts(actions);
|
|
711
|
-
sequencedCodeActions.push(resolved);
|
|
712
|
-
|
|
713
|
-
// Track dropped actions
|
|
714
|
-
const droppedCount = actions.length - resolved.length;
|
|
715
|
-
if (droppedCount > 0) {
|
|
716
|
-
const dropped = actions.filter(a => !resolved.includes(a));
|
|
717
|
-
droppedActions.push(...dropped);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Log dropped actions
|
|
722
|
-
if (droppedActions.length > 0) {
|
|
723
|
-
console.log(chalk.yellow(`\n⚠️ Dropped ${droppedActions.length} redundant operation(s) due to delete:`));
|
|
724
|
-
droppedActions.forEach(action => {
|
|
725
|
-
console.log(chalk.gray(` • ${action.action.toUpperCase()} on ${getActionDisplayName(action)} (superseded by DELETE)`));
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// Execute asset operations in parallel (they don't conflict)
|
|
730
|
-
const assetPromises = assets.map(async (action) => {
|
|
731
|
-
try {
|
|
732
|
-
const result = await executeAction(instanceUrl, token.value, action);
|
|
733
|
-
return { index: action.originalIndex, action, result, success: true };
|
|
734
|
-
} catch (error) {
|
|
735
|
-
return { index: action.originalIndex, action, error: error.message, success: false };
|
|
736
|
-
}
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
// Execute code entity operations sequentially per resource (but resources in parallel)
|
|
740
|
-
const codePromises = sequencedCodeActions.map(actions =>
|
|
741
|
-
executeResourceActions(instanceUrl, token.value, actions)
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
// Wait for all operations
|
|
745
|
-
const assetResults = await Promise.allSettled(assetPromises);
|
|
746
|
-
const codeResults = await Promise.allSettled(codePromises);
|
|
747
|
-
|
|
748
|
-
// Flatten code results
|
|
749
|
-
const allCodeResults = codeResults
|
|
750
|
-
.filter(r => r.status === 'fulfilled')
|
|
751
|
-
.flatMap(r => r.value);
|
|
752
|
-
|
|
753
|
-
const allAssetResults = assetResults
|
|
754
|
-
.map(r => r.status === 'fulfilled' ? r.value : { status: 'rejected', reason: r.reason });
|
|
755
|
-
|
|
756
|
-
// Combine all results
|
|
757
|
-
const allResults = [...allCodeResults, ...allAssetResults];
|
|
758
|
-
|
|
759
|
-
// Sort by original index to maintain order
|
|
760
|
-
allResults.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
761
|
-
|
|
762
|
-
// Process and display results
|
|
763
|
-
let successCount = 0;
|
|
764
|
-
let errorCount = 0;
|
|
765
|
-
|
|
766
|
-
for (const result of allResults) {
|
|
767
|
-
if (result.success !== undefined) {
|
|
768
|
-
const { index, action, success, error, result: operationResult } = result;
|
|
769
|
-
|
|
770
|
-
if (success) {
|
|
771
|
-
successCount++;
|
|
772
|
-
const displayName = getActionDisplayName(action);
|
|
773
|
-
console.log(
|
|
774
|
-
chalk.green(`✓ [${index + 1}]`) +
|
|
775
|
-
` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
|
|
776
|
-
(operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
|
|
777
|
-
);
|
|
778
|
-
await updateCacheAfterSuccess(action, operationResult);
|
|
779
|
-
} else {
|
|
780
|
-
errorCount++;
|
|
781
|
-
console.log();
|
|
782
|
-
console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
|
|
783
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
784
|
-
const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
|
|
785
|
-
console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${actionPath}):`));
|
|
786
|
-
console.log(formatMultilineError(error));
|
|
787
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
788
|
-
}
|
|
789
|
-
} else if (result.status === 'rejected') {
|
|
790
|
-
errorCount++;
|
|
791
|
-
console.log();
|
|
792
|
-
console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
|
|
793
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
794
|
-
console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
|
|
795
|
-
console.log(chalk.redBright('─'.repeat(48)));
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Summary
|
|
800
|
-
console.log(chalk.blue("\n--- Publish Summary ---"));
|
|
801
|
-
console.log(chalk.green(`✓ Successful: ${successCount}`));
|
|
802
|
-
if (droppedActions.length > 0) {
|
|
803
|
-
console.log(chalk.yellow(`⊝ Dropped: ${droppedActions.length} (redundant)`));
|
|
804
|
-
}
|
|
805
|
-
if (errorCount > 0) {
|
|
806
|
-
console.log(chalk.red(`✗ Failed: ${errorCount}`));
|
|
807
|
-
} else {
|
|
808
|
-
console.log(chalk.green("All operations completed successfully! 🎉"));
|
|
809
|
-
}
|
|
810
|
-
};
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* Executes all actions in the queue (dispatcher function).
|
|
814
|
-
*/
|
|
815
|
-
const performNetworkRequest = async (actionQueue) => {
|
|
816
|
-
if (USE_SEQUENTIAL_PROCESSING) {
|
|
817
|
-
return await performNetworkRequestSequential(actionQueue);
|
|
818
|
-
} else {
|
|
819
|
-
return await performNetworkRequestParallel(actionQueue);
|
|
820
|
-
}
|
|
821
|
-
};
|
|
822
|
-
|
|
823
|
-
/* ==================== MAIN PUBLISH LOGIC ==================== */
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Core publish logic - can be called from autopublish or directly.
|
|
827
|
-
* @param {Object} options - Configuration options
|
|
828
|
-
* @param {boolean} options.silent - If true, suppress summary output
|
|
829
|
-
* @returns {Promise<{actionQueue: Array, hasChanges: boolean}>}
|
|
830
|
-
*/
|
|
831
|
-
export const runPublish = async (options = {}) => {
|
|
832
|
-
const { silent = false } = options;
|
|
833
|
-
|
|
834
|
-
// Create progress tracker
|
|
835
|
-
const progress = silent ? null : new ProgressTracker('Publish to Magentrix');
|
|
836
|
-
if (progress) {
|
|
837
|
-
progress.addStep('auth', 'Authenticating...');
|
|
838
|
-
progress.addStep('load', 'Loading cached data...');
|
|
839
|
-
progress.addStep('scan', 'Scanning local files...');
|
|
840
|
-
progress.addStep('compare-assets', 'Comparing assets...', { hasProgress: true });
|
|
841
|
-
progress.addStep('compare-code', 'Comparing code entities...', { hasProgress: true });
|
|
842
|
-
progress.addStep('prepare', 'Preparing action queue...');
|
|
843
|
-
progress.start();
|
|
844
|
-
// Start first step immediately so UI shows up
|
|
845
|
-
progress.startStep('auth');
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// Step 1: Authenticate
|
|
849
|
-
await ensureValidCredentials().catch((err) => {
|
|
850
|
-
if (progress) {
|
|
851
|
-
progress.abort(err.message);
|
|
852
|
-
} else if (!silent) {
|
|
853
|
-
console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
|
|
854
|
-
}
|
|
855
|
-
throw err;
|
|
856
|
-
});
|
|
857
|
-
if (progress) progress.completeStep('auth', '✓ Authenticated');
|
|
858
|
-
|
|
859
|
-
// Step 2: Load cached file state
|
|
860
|
-
if (progress) progress.startStep('load');
|
|
861
|
-
|
|
862
|
-
const loadStart = Date.now();
|
|
863
|
-
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
864
|
-
const cachedResults = hits?.[0]?.value || {};
|
|
865
|
-
const loadTime = Date.now() - loadStart;
|
|
866
|
-
|
|
867
|
-
if (!Object.keys(cachedResults).length) {
|
|
868
|
-
if (progress) {
|
|
869
|
-
progress.abort('No file cache found');
|
|
870
|
-
} else if (!silent) {
|
|
871
|
-
console.log(chalk.red.bold("No file cache found!"));
|
|
872
|
-
console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
|
|
873
|
-
}
|
|
874
|
-
throw new Error("No file cache found");
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const mapStart = Date.now();
|
|
878
|
-
const cachedFiles = Object.values(cachedResults).map((c) => ({
|
|
879
|
-
...c,
|
|
880
|
-
tag: c.recordId,
|
|
881
|
-
filePath: c.filePath || c.lastKnownPath,
|
|
882
|
-
type: c.type || c.Type, // Normalize Type/type property
|
|
883
|
-
}));
|
|
884
|
-
const mapTime = Date.now() - mapStart;
|
|
885
|
-
|
|
886
|
-
if (progress) {
|
|
887
|
-
progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Step 3: Scan local workspace (only whitelisted code entity directories)
|
|
891
|
-
if (progress) progress.startStep('scan');
|
|
892
|
-
|
|
893
|
-
const walkStart = Date.now();
|
|
894
|
-
// Only scan whitelisted directories for code entities (exclude Assets and iris-apps, handled separately)
|
|
895
|
-
const codeEntityDirs = ALLOWED_SRC_DIRS.filter(dir => dir !== 'Assets' && dir !== IRIS_APPS_DIR);
|
|
896
|
-
const localPathArrays = await Promise.all(
|
|
897
|
-
codeEntityDirs.map(dir => {
|
|
898
|
-
const dirPath = path.join(EXPORT_ROOT, dir);
|
|
899
|
-
return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
|
|
900
|
-
})
|
|
901
|
-
);
|
|
902
|
-
const localPaths = localPathArrays.flat();
|
|
903
|
-
const walkTime = Date.now() - walkStart;
|
|
904
|
-
|
|
905
|
-
const tagStart = Date.now();
|
|
906
|
-
const localFiles = await Promise.all(
|
|
907
|
-
localPaths.map(async (p) => {
|
|
908
|
-
try {
|
|
909
|
-
const tag = await getFileTag(p);
|
|
910
|
-
return { tag, path: p };
|
|
911
|
-
} catch {
|
|
912
|
-
return { tag: null, path: p };
|
|
913
|
-
}
|
|
914
|
-
})
|
|
915
|
-
);
|
|
916
|
-
const tagTime = Date.now() - tagStart;
|
|
917
|
-
|
|
918
|
-
if (progress) {
|
|
919
|
-
progress.completeStep('scan', `✓ Found ${localPaths.length} files (${walkTime}ms walk, ${tagTime}ms tags)`);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// Step 4: Create lookup maps
|
|
923
|
-
if (progress) progress.startStep('compare-assets');
|
|
924
|
-
|
|
925
|
-
const mapBuildStart = Date.now();
|
|
926
|
-
const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
|
|
927
|
-
const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
|
|
928
|
-
const newLocalNoId = localFiles.filter((f) => !f.tag);
|
|
929
|
-
const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
|
|
930
|
-
const mapBuildTime = Date.now() - mapBuildStart;
|
|
931
|
-
|
|
932
|
-
const actionQueue = [];
|
|
933
|
-
|
|
934
|
-
// Step 5: Handle static asset files - Build fast lookup map first (O(n) instead of O(n²))
|
|
935
|
-
const assetWalkStart = Date.now();
|
|
936
|
-
const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
|
|
937
|
-
const assetWalkTime = Date.now() - assetWalkStart;
|
|
938
|
-
|
|
939
|
-
// Build a Set of normalized cached asset paths for O(1) lookup
|
|
940
|
-
const setStart = Date.now();
|
|
941
|
-
const cachedAssetPaths = new Set();
|
|
942
|
-
cachedFiles
|
|
943
|
-
.filter(cf => cf.type === 'File' || cf.type === 'Folder')
|
|
944
|
-
.forEach(cf => {
|
|
945
|
-
if (cf.lastKnownActualPath) {
|
|
946
|
-
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
|
|
947
|
-
}
|
|
948
|
-
if (cf.filePath) {
|
|
949
|
-
cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
|
|
950
|
-
}
|
|
951
|
-
if (cf.lastKnownPath) {
|
|
952
|
-
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
|
|
953
|
-
}
|
|
954
|
-
});
|
|
955
|
-
const setTime = Date.now() - setStart;
|
|
956
|
-
|
|
957
|
-
// Now compare assets with O(1) lookup
|
|
958
|
-
for (let i = 0; i < assetPaths.length; i++) {
|
|
959
|
-
const assetPath = assetPaths[i];
|
|
960
|
-
const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
|
|
961
|
-
|
|
962
|
-
// Update progress every 100 files
|
|
963
|
-
if (progress && i % 100 === 0) {
|
|
964
|
-
progress.updateProgress('compare-assets', i, assetPaths.length, `Checking ${i}/${assetPaths.length} assets`);
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// O(1) lookup instead of O(n) find()
|
|
968
|
-
if (cachedAssetPaths.has(normalizedAssetPath)) {
|
|
969
|
-
continue;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
actionQueue.push({
|
|
973
|
-
action: "create_static_asset",
|
|
974
|
-
folder: toApiPath(assetPath),
|
|
975
|
-
filePath: assetPath
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
if (progress) {
|
|
980
|
-
progress.completeStep('compare-assets', `✓ Compared ${assetPaths.length} assets (walk:${assetWalkTime}ms, set:${setTime}ms, map:${mapBuildTime}ms)`);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Step 6: Handle folder creation and deletion - Also optimized with Set
|
|
984
|
-
const assetsDir = path.join(EXPORT_ROOT, 'Assets');
|
|
985
|
-
if (fs.existsSync(assetsDir)) {
|
|
986
|
-
const localFolders = walkFolders(assetsDir);
|
|
987
|
-
const cachedFolders = cachedFiles
|
|
988
|
-
.filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath || c.lastKnownActualPath));
|
|
989
|
-
|
|
990
|
-
// Build Set of cached folder paths for O(1) lookup
|
|
991
|
-
const cachedFolderPaths = new Set();
|
|
992
|
-
cachedFolders.forEach(cf => {
|
|
993
|
-
if (cf.lastKnownActualPath) {
|
|
994
|
-
cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
|
|
995
|
-
}
|
|
996
|
-
if (cf.lastKnownPath) {
|
|
997
|
-
cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
|
|
998
|
-
}
|
|
999
|
-
if (cf.filePath) {
|
|
1000
|
-
cachedFolderPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
|
|
1001
|
-
}
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
// Build Set of local folder paths for O(1) lookup
|
|
1005
|
-
const localFolderPaths = new Set(
|
|
1006
|
-
localFolders.map(lf => path.normalize(path.resolve(lf)).toLowerCase())
|
|
1007
|
-
);
|
|
1008
|
-
|
|
1009
|
-
// New folders - O(1) lookup
|
|
1010
|
-
for (const folderPath of localFolders) {
|
|
1011
|
-
if (!folderPath) continue;
|
|
1012
|
-
|
|
1013
|
-
const normalizedFolderPath = path.normalize(path.resolve(folderPath)).toLowerCase();
|
|
1014
|
-
|
|
1015
|
-
if (cachedFolderPaths.has(normalizedFolderPath)) {
|
|
1016
|
-
continue;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const parentDir = path.dirname(folderPath);
|
|
1020
|
-
if (!parentDir || parentDir === '.' || parentDir === folderPath) continue;
|
|
1021
|
-
|
|
1022
|
-
actionQueue.push({
|
|
1023
|
-
action: "create_folder",
|
|
1024
|
-
folderPath: folderPath,
|
|
1025
|
-
parentPath: toApiFolderPath(parentDir),
|
|
1026
|
-
folderName: path.basename(folderPath)
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// Deleted folders - O(1) lookup
|
|
1031
|
-
for (const cachedFolder of cachedFolders) {
|
|
1032
|
-
const cachedPath = cachedFolder.lastKnownActualPath || cachedFolder.lastKnownPath || cachedFolder.filePath;
|
|
1033
|
-
if (!cachedPath || typeof cachedPath !== 'string') continue;
|
|
1034
|
-
|
|
1035
|
-
const normalizedCachedPath = path.normalize(path.resolve(cachedPath)).toLowerCase();
|
|
1036
|
-
|
|
1037
|
-
if (localFolderPaths.has(normalizedCachedPath)) {
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
const parentDir = path.dirname(cachedPath);
|
|
1042
|
-
if (!parentDir || parentDir === '.' || parentDir === cachedPath) continue;
|
|
1043
|
-
|
|
1044
|
-
actionQueue.push({
|
|
1045
|
-
action: "delete_folder",
|
|
1046
|
-
folderPath: cachedPath,
|
|
1047
|
-
parentPath: toApiFolderPath(parentDir),
|
|
1048
|
-
folderName: path.basename(cachedPath),
|
|
1049
|
-
baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// Step 7: Process code entities (ActiveClass/ActivePage) and static assets
|
|
1055
|
-
if (progress) progress.startStep('compare-code');
|
|
1056
|
-
|
|
1057
|
-
const allIdsArray = Array.from(allIds);
|
|
1058
|
-
for (let idx = 0; idx < allIdsArray.length; idx++) {
|
|
1059
|
-
const id = allIdsArray[idx];
|
|
1060
|
-
|
|
1061
|
-
// Update progress every 50 items
|
|
1062
|
-
if (progress && idx % 50 === 0) {
|
|
1063
|
-
progress.updateProgress('compare-code', idx, allIdsArray.length, `Checking ${idx}/${allIdsArray.length} entities`);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
try {
|
|
1067
|
-
const cacheFile = cacheById[id];
|
|
1068
|
-
const curFile = localById[id];
|
|
1069
|
-
|
|
1070
|
-
// Skip folders - they're handled separately
|
|
1071
|
-
if (cacheFile?.type === 'Folder') continue;
|
|
1072
|
-
|
|
1073
|
-
// Skip Iris apps - they're handled separately (check both type and ID prefix)
|
|
1074
|
-
if (cacheFile?.type === 'IrisApp' || id.startsWith('iris-app:')) continue;
|
|
1075
|
-
|
|
1076
|
-
// Handle static asset files
|
|
1077
|
-
if (cacheFile?.type === 'File') {
|
|
1078
|
-
// Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
|
|
1079
|
-
const actualPath = cacheFile.lastKnownActualPath || cacheFile.filePath;
|
|
1080
|
-
const localAssetExists = fs.existsSync(actualPath);
|
|
1081
|
-
|
|
1082
|
-
if (!localAssetExists) {
|
|
1083
|
-
actionQueue.push({
|
|
1084
|
-
action: 'delete_static_asset',
|
|
1085
|
-
folder: toApiPath(actualPath),
|
|
1086
|
-
names: [path.basename(actualPath)],
|
|
1087
|
-
filePath: actualPath, // Store actual file path for filtering
|
|
1088
|
-
baseKey: id // The original base.json key for correct cache cleanup
|
|
1089
|
-
});
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
const contentHash = sha256(fs.readFileSync(actualPath, 'utf-8'));
|
|
1094
|
-
if (contentHash !== cacheFile.contentHash) {
|
|
1095
|
-
actionQueue.push({
|
|
1096
|
-
action: "create_static_asset",
|
|
1097
|
-
folder: toApiPath(actualPath),
|
|
1098
|
-
filePath: actualPath
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
|
-
continue;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Handle code entity deletion
|
|
1105
|
-
if (cacheFile && !curFile) {
|
|
1106
|
-
const { type, entity } = inferMeta(path.resolve(cacheFile.lastKnownPath));
|
|
1107
|
-
actionQueue.push({
|
|
1108
|
-
action: "delete",
|
|
1109
|
-
recordId: id,
|
|
1110
|
-
filePath: cacheFile.lastKnownPath,
|
|
1111
|
-
type,
|
|
1112
|
-
entity,
|
|
1113
|
-
});
|
|
1114
|
-
continue;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
// Handle code entity creation
|
|
1118
|
-
if (!cacheFile && curFile) {
|
|
1119
|
-
const safe = readFileSafe(curFile.path);
|
|
1120
|
-
if (!safe) continue;
|
|
1121
|
-
|
|
1122
|
-
const { content, hash } = safe;
|
|
1123
|
-
const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
|
|
1124
|
-
|
|
1125
|
-
actionQueue.push({
|
|
1126
|
-
action: "create",
|
|
1127
|
-
filePath: curFile.path,
|
|
1128
|
-
recordId: id || null,
|
|
1129
|
-
type,
|
|
1130
|
-
entity,
|
|
1131
|
-
fields: { [contentField]: content },
|
|
1132
|
-
contentHash: hash,
|
|
1133
|
-
});
|
|
1134
|
-
continue;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// Handle code entity update
|
|
1138
|
-
if (cacheFile && curFile) {
|
|
1139
|
-
const safe = readFileSafe(curFile.path);
|
|
1140
|
-
if (!safe) continue;
|
|
1141
|
-
|
|
1142
|
-
const { content, hash } = safe;
|
|
1143
|
-
// Check both paths - only consider renamed if NEITHER matches current path
|
|
1144
|
-
// This prevents false positives from stale/corrupted tracking data
|
|
1145
|
-
const resolvedCurPath = path.resolve(curFile.path);
|
|
1146
|
-
const matchesActualPath = cacheFile.lastKnownActualPath === resolvedCurPath;
|
|
1147
|
-
const matchesExpectedPath = cacheFile.lastKnownPath === resolvedCurPath;
|
|
1148
|
-
const renamed = !matchesActualPath && !matchesExpectedPath;
|
|
1149
|
-
const contentChanged = hash !== cacheFile.contentHash;
|
|
1150
|
-
|
|
1151
|
-
if (renamed || contentChanged) {
|
|
1152
|
-
const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
|
|
1153
|
-
|
|
1154
|
-
actionQueue.push({
|
|
1155
|
-
action: "update",
|
|
1156
|
-
recordId: id,
|
|
1157
|
-
type,
|
|
1158
|
-
entity,
|
|
1159
|
-
fields: { [contentField]: content },
|
|
1160
|
-
renamed,
|
|
1161
|
-
oldPath: cacheFile.lastKnownActualPath || cacheFile.lastKnownPath,
|
|
1162
|
-
filePath: curFile.path,
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
} catch (err) {
|
|
1167
|
-
if (!silent) {
|
|
1168
|
-
console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
if (progress) {
|
|
1174
|
-
progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Step 7b: Scan and compare Iris apps
|
|
1178
|
-
const irisAppsPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR);
|
|
1179
|
-
|
|
1180
|
-
// Get cached Iris apps (always check, even if local folder doesn't exist)
|
|
1181
|
-
// Also include entries with iris-app: prefix in case type wasn't properly set
|
|
1182
|
-
const cachedIrisApps = cachedFiles
|
|
1183
|
-
.filter(cf => cf.type === 'IrisApp' || (cf.tag && cf.tag.startsWith('iris-app:')))
|
|
1184
|
-
.map(cf => {
|
|
1185
|
-
// Extract slug from folderName, or from the ID (iris-app:<slug>)
|
|
1186
|
-
const slug = cf.folderName || (cf.tag && cf.tag.startsWith('iris-app:') ? cf.tag.replace('iris-app:', '') : null);
|
|
1187
|
-
return {
|
|
1188
|
-
slug,
|
|
1189
|
-
appName: cf.appName || slug,
|
|
1190
|
-
modifiedOn: cf.modifiedOn,
|
|
1191
|
-
contentHash: cf.contentHash || null // Track content hash for change detection
|
|
1192
|
-
};
|
|
1193
|
-
})
|
|
1194
|
-
.filter(app => app.slug); // Filter out any entries without a valid slug
|
|
1195
|
-
|
|
1196
|
-
// Get local Iris apps (empty if folder doesn't exist)
|
|
1197
|
-
const localIrisApps = fs.existsSync(irisAppsPath)
|
|
1198
|
-
? fs.readdirSync(irisAppsPath, { withFileTypes: true })
|
|
1199
|
-
.filter(d => d.isDirectory())
|
|
1200
|
-
.map(d => d.name)
|
|
1201
|
-
: [];
|
|
1202
|
-
|
|
1203
|
-
const cachedIrisSlugs = new Set(cachedIrisApps.map(a => a.slug));
|
|
1204
|
-
const localIrisSlugs = new Set(localIrisApps);
|
|
1205
|
-
|
|
1206
|
-
// Detect new and modified Iris apps
|
|
1207
|
-
for (const slug of localIrisApps) {
|
|
1208
|
-
const appPath = path.join(irisAppsPath, slug);
|
|
1209
|
-
|
|
1210
|
-
// Validate the app folder has required files
|
|
1211
|
-
const validation = validateIrisAppFolder(appPath);
|
|
1212
|
-
if (!validation.valid) {
|
|
1213
|
-
if (!silent) {
|
|
1214
|
-
console.log(chalk.yellow(`⚠ Skipping invalid Iris app '${slug}': ${validation.errors.join(', ')}`));
|
|
1215
|
-
}
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Get app metadata from linked project config (stored globally) or use slug
|
|
1220
|
-
const linkedProjects = getLinkedProjects();
|
|
1221
|
-
const linkedProject = linkedProjects.find(p => p.slug === slug);
|
|
1222
|
-
const appName = linkedProject?.appName || slug;
|
|
1223
|
-
const appDescription = linkedProject?.appDescription || null;
|
|
1224
|
-
const appIconId = linkedProject?.appIconId || null;
|
|
1225
|
-
|
|
1226
|
-
// Calculate content hash for change detection
|
|
1227
|
-
const currentHash = hashIrisAppFolder(appPath);
|
|
1228
|
-
|
|
1229
|
-
// Check if this is a new or existing app
|
|
1230
|
-
const isNew = !cachedIrisSlugs.has(slug);
|
|
1231
|
-
|
|
1232
|
-
if (isNew) {
|
|
1233
|
-
actionQueue.push({
|
|
1234
|
-
action: 'create_iris_app',
|
|
1235
|
-
slug,
|
|
1236
|
-
appName,
|
|
1237
|
-
appDescription,
|
|
1238
|
-
appIconId,
|
|
1239
|
-
appPath,
|
|
1240
|
-
contentHash: currentHash
|
|
1241
|
-
});
|
|
1242
|
-
} else {
|
|
1243
|
-
// Only update if content has changed
|
|
1244
|
-
const cachedApp = cachedIrisApps.find(a => a.slug === slug);
|
|
1245
|
-
const contentChanged = !cachedApp?.contentHash || cachedApp.contentHash !== currentHash;
|
|
1246
|
-
|
|
1247
|
-
if (contentChanged) {
|
|
1248
|
-
actionQueue.push({
|
|
1249
|
-
action: 'update_iris_app',
|
|
1250
|
-
slug,
|
|
1251
|
-
appName: linkedProject?.appName || cachedApp?.appName || slug,
|
|
1252
|
-
appDescription,
|
|
1253
|
-
appIconId,
|
|
1254
|
-
appPath,
|
|
1255
|
-
contentHash: currentHash
|
|
1256
|
-
});
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// Detect deleted Iris apps (works even if iris-apps folder doesn't exist)
|
|
1262
|
-
for (const cachedApp of cachedIrisApps) {
|
|
1263
|
-
if (!localIrisSlugs.has(cachedApp.slug)) {
|
|
1264
|
-
actionQueue.push({
|
|
1265
|
-
action: 'delete_iris_app',
|
|
1266
|
-
slug: cachedApp.slug
|
|
1267
|
-
});
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Step 8: Handle brand-new, tag-less files
|
|
1272
|
-
if (progress) progress.startStep('prepare');
|
|
1273
|
-
|
|
1274
|
-
for (const f of newLocalNoId) {
|
|
1275
|
-
const safe = readFileSafe(f.path);
|
|
1276
|
-
if (!safe) {
|
|
1277
|
-
if (!silent) {
|
|
1278
|
-
console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
|
|
1279
|
-
}
|
|
1280
|
-
continue;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const { content, hash } = safe;
|
|
1284
|
-
const { type, entity, contentField } = inferMeta(path.resolve(f.path));
|
|
1285
|
-
|
|
1286
|
-
actionQueue.push({
|
|
1287
|
-
action: "create",
|
|
1288
|
-
filePath: f.path,
|
|
1289
|
-
recordId: null,
|
|
1290
|
-
type,
|
|
1291
|
-
entity,
|
|
1292
|
-
fields: { [contentField]: content },
|
|
1293
|
-
contentHash: hash,
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// Step 9: Filter out redundant file and folder deletions
|
|
1298
|
-
const foldersBeingDeleted = actionQueue
|
|
1299
|
-
.filter(a => a.action === 'delete_folder')
|
|
1300
|
-
.map(a => a.folderPath);
|
|
1301
|
-
|
|
1302
|
-
const filteredActionQueue = actionQueue.filter(action => {
|
|
1303
|
-
// Filter out files inside folders being deleted
|
|
1304
|
-
if (action.action === 'delete_static_asset' && action.filePath) {
|
|
1305
|
-
const fileDir = path.dirname(action.filePath);
|
|
1306
|
-
for (const deletedFolder of foldersBeingDeleted) {
|
|
1307
|
-
const normalizedFileDir = path.normalize(fileDir);
|
|
1308
|
-
const normalizedDeletedFolder = path.normalize(deletedFolder);
|
|
1309
|
-
|
|
1310
|
-
if (normalizedFileDir === normalizedDeletedFolder ||
|
|
1311
|
-
normalizedFileDir.startsWith(normalizedDeletedFolder + path.sep)) {
|
|
1312
|
-
return false; // Skip - covered by folder deletion
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
// Filter out child folders inside folders being deleted
|
|
1318
|
-
if (action.action === 'delete_folder' && action.folderPath) {
|
|
1319
|
-
for (const deletedFolder of foldersBeingDeleted) {
|
|
1320
|
-
// Don't compare a folder to itself
|
|
1321
|
-
if (action.folderPath === deletedFolder) continue;
|
|
1322
|
-
|
|
1323
|
-
const normalizedChildFolder = path.normalize(action.folderPath);
|
|
1324
|
-
const normalizedParentFolder = path.normalize(deletedFolder);
|
|
1325
|
-
|
|
1326
|
-
// Check if this folder is inside another folder being deleted
|
|
1327
|
-
if (normalizedChildFolder.startsWith(normalizedParentFolder + path.sep)) {
|
|
1328
|
-
return false; // Skip - covered by parent folder deletion
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
return true;
|
|
1334
|
-
});
|
|
1335
|
-
|
|
1336
|
-
if (progress) {
|
|
1337
|
-
progress.completeStep('prepare', `✓ Prepared ${filteredActionQueue.length} actions`);
|
|
1338
|
-
progress.finish();
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
// Step 10: Display and execute action queue
|
|
1342
|
-
if (!silent) {
|
|
1343
|
-
console.log(chalk.blue("\n--- Publish Action Queue ---"));
|
|
1344
|
-
if (!filteredActionQueue.length) {
|
|
1345
|
-
console.log(chalk.green("All files are in sync — nothing to publish!"));
|
|
1346
|
-
} else {
|
|
1347
|
-
filteredActionQueue.forEach((a, i) => {
|
|
1348
|
-
const num = chalk.green(`[${i + 1}]`);
|
|
1349
|
-
const act = chalk.yellow(a.action.toUpperCase());
|
|
1350
|
-
|
|
1351
|
-
let type, displayPath, label;
|
|
1352
|
-
if (a.slug) {
|
|
1353
|
-
// Iris app action
|
|
1354
|
-
type = chalk.cyan(`${a.appName || a.slug}`);
|
|
1355
|
-
displayPath = a.appPath || a.slug;
|
|
1356
|
-
label = "App";
|
|
1357
|
-
} else if (a.folderName) {
|
|
1358
|
-
type = chalk.cyan(a.folderName);
|
|
1359
|
-
displayPath = a.folderPath;
|
|
1360
|
-
label = "Folder";
|
|
1361
|
-
} else if (a.names) {
|
|
1362
|
-
type = chalk.cyan(a.names.join(", "));
|
|
1363
|
-
displayPath = a.folder;
|
|
1364
|
-
label = "File";
|
|
1365
|
-
} else if (a.filePath) {
|
|
1366
|
-
type = chalk.cyan(a.type || path.basename(a.filePath));
|
|
1367
|
-
displayPath = a.filePath;
|
|
1368
|
-
label = a.type ? "Type" : "File";
|
|
1369
|
-
} else {
|
|
1370
|
-
type = chalk.cyan(a.type || "Unknown");
|
|
1371
|
-
displayPath = a.folder || "Unknown";
|
|
1372
|
-
label = "File";
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
|
|
1376
|
-
const renameInfo = a.renamed
|
|
1377
|
-
? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
|
|
1378
|
-
: "";
|
|
1379
|
-
|
|
1380
|
-
console.log(`${num} ${act} | ${label}: ${type}${idInfo}${renameInfo} (${displayPath})`);
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
console.log(chalk.blue("\n--- Publishing Changes ---"));
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
// Execute actions
|
|
1388
|
-
if (filteredActionQueue.length > 0) {
|
|
1389
|
-
if (silent) {
|
|
1390
|
-
await performNetworkRequest(filteredActionQueue);
|
|
1391
|
-
} else {
|
|
1392
|
-
await withSpinner(
|
|
1393
|
-
"Working...",
|
|
1394
|
-
async () => {
|
|
1395
|
-
await performNetworkRequest(filteredActionQueue)
|
|
1396
|
-
},
|
|
1397
|
-
{ showCompletion: false }
|
|
1398
|
-
);
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
return {
|
|
1403
|
-
actionQueue: filteredActionQueue,
|
|
1404
|
-
hasChanges: filteredActionQueue.length > 0
|
|
1405
|
-
};
|
|
1406
|
-
};
|
|
1407
|
-
|
|
1408
|
-
/**
|
|
1409
|
-
* CLI command wrapper for publish.
|
|
1410
|
-
*/
|
|
1411
|
-
export const publish = async () => {
|
|
1412
|
-
process.stdout.write("\x1Bc"); // clear console
|
|
1413
|
-
|
|
1414
|
-
try {
|
|
1415
|
-
await runPublish({ silent: false });
|
|
1416
|
-
} catch (err) {
|
|
1417
|
-
console.error(chalk.red.bold("Publish failed:"), err.message);
|
|
1418
|
-
process.exit(1);
|
|
1419
|
-
}
|
|
1420
|
-
};
|
|
1
|
+
import { walkFiles } from "../utils/cacher.js";
|
|
2
|
+
import { ensureValidCredentials } from "../utils/cli/helpers/ensureCredentials.js";
|
|
3
|
+
import Config from "../utils/config.js";
|
|
4
|
+
import { withSpinner } from "../utils/spinner.js";
|
|
5
|
+
import { ProgressTracker } from "../utils/progress.js";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import {
|
|
10
|
+
ENTITY_FIELD_MAP,
|
|
11
|
+
ENTITY_TYPE_MAP,
|
|
12
|
+
EXPORT_ROOT,
|
|
13
|
+
TYPE_DIR_MAP,
|
|
14
|
+
IRIS_APPS_DIR,
|
|
15
|
+
ALLOWED_SRC_DIRS,
|
|
16
|
+
} from "../vars/global.js";
|
|
17
|
+
import { getFileTag, setFileTag } from "../utils/filetag.js";
|
|
18
|
+
import { sha256 } from "../utils/hash.js";
|
|
19
|
+
import { createEntity } from "../utils/magentrix/api/createEntity.js";
|
|
20
|
+
import { updateEntity } from "../utils/magentrix/api/updateEntity.js";
|
|
21
|
+
import { deleteEntity } from "../utils/magentrix/api/deleteEntity.js";
|
|
22
|
+
import { removeFromBase, updateBase } from "../utils/updateFileBase.js";
|
|
23
|
+
import { deleteAsset, uploadAsset, createFolder } from "../utils/magentrix/api/assets.js";
|
|
24
|
+
import { toApiPath, toApiFolderPath } from "../utils/assetPaths.js";
|
|
25
|
+
import { publishApp } from "../utils/magentrix/api/iris.js";
|
|
26
|
+
import { createIrisZip, hashIrisAppFolder } from "../utils/iris/zipper.js";
|
|
27
|
+
import { validateIrisAppFolder } from "../utils/iris/validator.js";
|
|
28
|
+
import { getLinkedProjects } from "../utils/iris/linker.js";
|
|
29
|
+
import { deleteIrisAppFromServer } from "../utils/iris/deleteHelper.js";
|
|
30
|
+
|
|
31
|
+
const config = new Config();
|
|
32
|
+
|
|
33
|
+
/* ==================== CONFIGURATION ==================== */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set to true to process all operations sequentially (one at a time).
|
|
37
|
+
* Set to false to process operations in parallel with intelligent grouping.
|
|
38
|
+
*
|
|
39
|
+
* Sequential mode is slower but avoids any potential race conditions or
|
|
40
|
+
* server-side rate limiting issues.
|
|
41
|
+
*/
|
|
42
|
+
const USE_SEQUENTIAL_PROCESSING = true;
|
|
43
|
+
|
|
44
|
+
/* ==================== UTILITY FUNCTIONS ==================== */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Infers Magentrix entity metadata from a file path by walking up the directory tree.
|
|
48
|
+
* @param {string} filePath - The file path to analyze
|
|
49
|
+
* @returns {Object} { type, entity, contentField } for Magentrix API
|
|
50
|
+
*/
|
|
51
|
+
const inferMeta = (filePath) => {
|
|
52
|
+
let currentPath = path.dirname(filePath);
|
|
53
|
+
const exportRootAbs = path.resolve(EXPORT_ROOT);
|
|
54
|
+
|
|
55
|
+
while (currentPath.startsWith(exportRootAbs)) {
|
|
56
|
+
const folderName = path.basename(currentPath);
|
|
57
|
+
const type = Object.keys(TYPE_DIR_MAP).find(
|
|
58
|
+
(k) => TYPE_DIR_MAP[k].directory === folderName
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (type) {
|
|
62
|
+
return {
|
|
63
|
+
type,
|
|
64
|
+
entity: ENTITY_TYPE_MAP[type],
|
|
65
|
+
contentField: ENTITY_FIELD_MAP[type],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parentPath = path.dirname(currentPath);
|
|
70
|
+
if (parentPath === currentPath) break; // Reached root
|
|
71
|
+
currentPath = parentPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { type: undefined, entity: undefined, contentField: undefined };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Safely reads file content and computes hash.
|
|
79
|
+
* @param {string} filePath - Path to the file
|
|
80
|
+
* @returns {Object|null} { content, hash } or null on error
|
|
81
|
+
*/
|
|
82
|
+
const readFileSafe = (filePath) => {
|
|
83
|
+
try {
|
|
84
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
85
|
+
return { content, hash: sha256(content) };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(chalk.red.bold("Error:") + " Unable to read file " + chalk.white.bold(filePath));
|
|
88
|
+
console.error(chalk.gray("→ Please verify the file exists and you have read permissions."));
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Formats multiline error messages with proper indentation.
|
|
95
|
+
*/
|
|
96
|
+
const formatMultilineError = (err) => {
|
|
97
|
+
const lines = String(err).split(/\r?\n/);
|
|
98
|
+
return lines
|
|
99
|
+
.map((line, i) => {
|
|
100
|
+
const prefix = i === 0 ? `${chalk.redBright(' •')} ` : ' ';
|
|
101
|
+
return prefix + chalk.whiteBright(line);
|
|
102
|
+
})
|
|
103
|
+
.join('\n');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recursively walks a directory and returns all subdirectory paths.
|
|
108
|
+
*/
|
|
109
|
+
const walkFolders = (dir) => {
|
|
110
|
+
if (!fs.existsSync(dir)) return [];
|
|
111
|
+
|
|
112
|
+
const folders = [];
|
|
113
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
114
|
+
|
|
115
|
+
for (const item of items) {
|
|
116
|
+
if (item.isDirectory()) {
|
|
117
|
+
const fullPath = path.join(dir, item.name);
|
|
118
|
+
folders.push(fullPath);
|
|
119
|
+
folders.push(...walkFolders(fullPath));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return folders;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gets display name for an action in log messages.
|
|
128
|
+
*/
|
|
129
|
+
const getActionDisplayName = (action) => {
|
|
130
|
+
// Iris app actions
|
|
131
|
+
if (action.slug) return `${action.appName || action.slug} (${action.slug})`;
|
|
132
|
+
if (action.folderName) return action.folderName;
|
|
133
|
+
if (action.names) return action.names.join(", ");
|
|
134
|
+
if (action.type) return action.type;
|
|
135
|
+
if (action.filePath) return path.basename(action.filePath);
|
|
136
|
+
return '';
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/* ==================== ACTION HANDLERS ==================== */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Handles CREATE action for code entities (ActiveClass/ActivePage).
|
|
143
|
+
*/
|
|
144
|
+
const handleCreateAction = async (instanceUrl, apiKey, action) => {
|
|
145
|
+
const data = {
|
|
146
|
+
Name: path.basename(action.filePath, path.extname(action.filePath)),
|
|
147
|
+
Type: action.type,
|
|
148
|
+
...action.fields
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const result = await createEntity(instanceUrl, apiKey, action.entity, data);
|
|
152
|
+
return { recordId: result.recordId || result.id };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Handles UPDATE action for code entities (ActiveClass/ActivePage).
|
|
157
|
+
*/
|
|
158
|
+
const handleUpdateAction = async (instanceUrl, apiKey, action) => {
|
|
159
|
+
const data = { ...action.fields };
|
|
160
|
+
|
|
161
|
+
if (action.renamed) {
|
|
162
|
+
data.Name = path.basename(action.filePath, path.extname(action.filePath));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await updateEntity(instanceUrl, apiKey, action.entity, action.recordId, data);
|
|
166
|
+
return { recordId: action.recordId };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Handles DELETE action for code entities (ActiveClass/ActivePage).
|
|
171
|
+
*/
|
|
172
|
+
const handleDeleteAction = async (instanceUrl, apiKey, action) => {
|
|
173
|
+
return await deleteEntity(instanceUrl, apiKey, action.entity, action.recordId);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Handles CREATE_STATIC_ASSET action.
|
|
178
|
+
*/
|
|
179
|
+
const handleCreateStaticAssetAction = async (instanceUrl, apiKey, action) => {
|
|
180
|
+
const response = await uploadAsset(instanceUrl, apiKey, `/${action.folder}`, [action.filePath]);
|
|
181
|
+
if (response?.error) throw new Error(response.error);
|
|
182
|
+
return response;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handles DELETE_STATIC_ASSET action.
|
|
187
|
+
*/
|
|
188
|
+
const handleDeleteStaticAssetAction = async (instanceUrl, apiKey, action) => {
|
|
189
|
+
try {
|
|
190
|
+
const response = await deleteAsset(instanceUrl, apiKey, `/${action.folder}`, action.names);
|
|
191
|
+
return response;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Check if this is a "not found" error
|
|
194
|
+
const errorMessage = error?.message || String(error);
|
|
195
|
+
const errorLower = errorMessage.toLowerCase();
|
|
196
|
+
const isNotFound = errorLower.includes('404') ||
|
|
197
|
+
errorLower.includes('not found') ||
|
|
198
|
+
errorLower.includes('item not found');
|
|
199
|
+
|
|
200
|
+
if (isNotFound) {
|
|
201
|
+
// Clean up base.json since file doesn't exist on server
|
|
202
|
+
// Use the original base.json key if available (avoids path format mismatches)
|
|
203
|
+
if (action.baseKey) {
|
|
204
|
+
removeFromBase(action.baseKey);
|
|
205
|
+
} else {
|
|
206
|
+
for (const name of action.names) {
|
|
207
|
+
const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
|
|
208
|
+
removeFromBase(filePath);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { cleanedFromCache: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Other errors should still fail
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Handles CREATE_FOLDER action.
|
|
221
|
+
*/
|
|
222
|
+
const handleCreateFolderAction = async (instanceUrl, apiKey, action) => {
|
|
223
|
+
try {
|
|
224
|
+
const response = await createFolder(instanceUrl, apiKey, action.parentPath, action.folderName);
|
|
225
|
+
return response;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Check if folder already exists (likely created by file upload)
|
|
228
|
+
const errorMessage = error?.message || String(error);
|
|
229
|
+
const errorLower = errorMessage.toLowerCase();
|
|
230
|
+
const alreadyExists = errorLower.includes('already exists') ||
|
|
231
|
+
errorLower.includes('folder exists') ||
|
|
232
|
+
errorLower.includes('duplicate');
|
|
233
|
+
|
|
234
|
+
if (alreadyExists) {
|
|
235
|
+
// Folder already exists, update cache and treat as success
|
|
236
|
+
return { alreadyExisted: true };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Other errors should still fail
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Handles DELETE_FOLDER action.
|
|
246
|
+
*/
|
|
247
|
+
const handleDeleteFolderAction = async (instanceUrl, apiKey, action) => {
|
|
248
|
+
try {
|
|
249
|
+
const response = await deleteAsset(instanceUrl, apiKey, action.parentPath, [action.folderName]);
|
|
250
|
+
return response;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// Check if this is a "not found" error
|
|
253
|
+
const errorMessage = error?.message || String(error);
|
|
254
|
+
const errorLower = errorMessage.toLowerCase();
|
|
255
|
+
const isNotFound = errorLower.includes('404') ||
|
|
256
|
+
errorLower.includes('not found') ||
|
|
257
|
+
errorLower.includes('item not found');
|
|
258
|
+
|
|
259
|
+
if (isNotFound) {
|
|
260
|
+
// Clean up base.json since folder doesn't exist on server
|
|
261
|
+
// Use original base.json key if available (avoids path format mismatches)
|
|
262
|
+
removeFromBase(action.baseKey || action.folderPath);
|
|
263
|
+
|
|
264
|
+
// Also remove all files and subfolders inside this folder from base
|
|
265
|
+
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
266
|
+
const cachedResults = hits?.[0]?.value || {};
|
|
267
|
+
|
|
268
|
+
for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
|
|
269
|
+
// Check all possible path fields
|
|
270
|
+
const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
|
|
271
|
+
if (entryPath && typeof entryPath === 'string') {
|
|
272
|
+
const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
|
|
273
|
+
const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
|
|
274
|
+
|
|
275
|
+
// Check if this entry is inside the deleted folder
|
|
276
|
+
if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
|
|
277
|
+
normalizedEntryPath === normalizedFolderPath) {
|
|
278
|
+
removeFromBase(recordId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { cleanedFromCache: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Other errors should still fail
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Handles CREATE_IRIS_APP or UPDATE_IRIS_APP action.
|
|
293
|
+
*/
|
|
294
|
+
const handlePublishIrisAppAction = async (instanceUrl, apiKey, action) => {
|
|
295
|
+
// Create zip from the app folder
|
|
296
|
+
const zipBuffer = await createIrisZip(action.appPath, action.slug);
|
|
297
|
+
|
|
298
|
+
// For updates, don't send app-name/description/icon to avoid triggering
|
|
299
|
+
// metadata modification that may require permissions the user doesn't have.
|
|
300
|
+
// The server already has the correct metadata from the original create.
|
|
301
|
+
// For creates, send all metadata so the app is properly registered.
|
|
302
|
+
const isUpdate = action.action === 'update_iris_app';
|
|
303
|
+
|
|
304
|
+
const response = await publishApp(
|
|
305
|
+
instanceUrl,
|
|
306
|
+
apiKey,
|
|
307
|
+
zipBuffer,
|
|
308
|
+
`${action.slug}.zip`,
|
|
309
|
+
isUpdate ? null : action.appName,
|
|
310
|
+
isUpdate ? {} : {
|
|
311
|
+
appDescription: action.appDescription,
|
|
312
|
+
appIconId: action.appIconId
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
return response;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handles DELETE_IRIS_APP action.
|
|
321
|
+
*/
|
|
322
|
+
const handleDeleteIrisAppAction = async (instanceUrl, apiKey, action) => {
|
|
323
|
+
// Use shared delete utility for consistency
|
|
324
|
+
return await deleteIrisAppFromServer(instanceUrl, apiKey, action.slug, {
|
|
325
|
+
updateCache: true // Cache will be updated by the utility
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Synchronizes class name in content with filename, or vice versa.
|
|
331
|
+
*/
|
|
332
|
+
const syncClassAndFileNames = (action, recordId) => {
|
|
333
|
+
// Only for ActiveClass
|
|
334
|
+
if (action.entity !== 'ActiveClass') return;
|
|
335
|
+
|
|
336
|
+
const filePath = action.filePath;
|
|
337
|
+
// If file was deleted or doesn't exist, skip
|
|
338
|
+
if (!fs.existsSync(filePath)) return;
|
|
339
|
+
|
|
340
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
341
|
+
const filename = path.basename(filePath, path.extname(filePath));
|
|
342
|
+
|
|
343
|
+
// Regex to find class/interface/enum name
|
|
344
|
+
// Matches: public class Name, public interface Name, public enum Name
|
|
345
|
+
// We assume standard formatting
|
|
346
|
+
const classRegex = /public\s+(?:class|interface|enum)\s+(\w+)/;
|
|
347
|
+
const match = content.match(classRegex);
|
|
348
|
+
|
|
349
|
+
if (match) {
|
|
350
|
+
const classNameInContent = match[1];
|
|
351
|
+
|
|
352
|
+
if (classNameInContent !== filename) {
|
|
353
|
+
// Mismatch detected
|
|
354
|
+
|
|
355
|
+
// Case 1: File was renamed (action.renamed is true) -> Update content
|
|
356
|
+
if (action.renamed) {
|
|
357
|
+
const newContent = content.replace(classRegex, (fullMatch, name) => {
|
|
358
|
+
return fullMatch.replace(name, filename);
|
|
359
|
+
});
|
|
360
|
+
fs.writeFileSync(filePath, newContent);
|
|
361
|
+
console.log(chalk.cyan(` ↻ Updated class name in file to: ${filename}`));
|
|
362
|
+
|
|
363
|
+
// Update cache with new content hash
|
|
364
|
+
updateBase(filePath, { Id: recordId, Type: 'ActiveClass' });
|
|
365
|
+
}
|
|
366
|
+
// Case 2: Content was updated (action.renamed is false) -> Rename file
|
|
367
|
+
else {
|
|
368
|
+
// Rename file to match class name
|
|
369
|
+
const dir = path.dirname(filePath);
|
|
370
|
+
const ext = path.extname(filePath);
|
|
371
|
+
const newFilename = `${classNameInContent}${ext}`;
|
|
372
|
+
const newFilePath = path.join(dir, newFilename);
|
|
373
|
+
|
|
374
|
+
if (fs.existsSync(newFilePath)) {
|
|
375
|
+
console.warn(chalk.yellow(` ⚠️ Cannot rename ${filename} to ${classNameInContent}: File already exists.`));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
fs.renameSync(filePath, newFilePath);
|
|
381
|
+
console.log(chalk.cyan(` ↻ Renamed file to match class: ${newFilename}`));
|
|
382
|
+
|
|
383
|
+
// Update cache: update the entry for this recordId to point to new path
|
|
384
|
+
updateBase(newFilePath, { Id: recordId, Type: 'ActiveClass' }, newFilePath);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.warn(chalk.yellow(` ⚠️ Failed to rename file: ${err.message}`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Updates cache after successful operations.
|
|
395
|
+
*/
|
|
396
|
+
const updateCacheAfterSuccess = async (action, operationResult) => {
|
|
397
|
+
try {
|
|
398
|
+
// Sync class names/filenames if needed (Bug 2 Fix)
|
|
399
|
+
if (action.action === 'update' && operationResult?.recordId) {
|
|
400
|
+
syncClassAndFileNames(action, operationResult.recordId);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
switch (action.action) {
|
|
404
|
+
case "create": {
|
|
405
|
+
const createSnapshot = action.fields && Object.values(action.fields)[0]
|
|
406
|
+
? { content: Object.values(action.fields)[0], hash: action.contentHash }
|
|
407
|
+
: null;
|
|
408
|
+
await setFileTag(action.filePath, operationResult.recordId);
|
|
409
|
+
updateBase(
|
|
410
|
+
action.filePath,
|
|
411
|
+
{ Id: operationResult.recordId, Type: action.type },
|
|
412
|
+
'',
|
|
413
|
+
createSnapshot
|
|
414
|
+
);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case "update": {
|
|
419
|
+
const updateSnapshot = action.fields && Object.values(action.fields)[0]
|
|
420
|
+
? { content: Object.values(action.fields)[0], hash: sha256(Object.values(action.fields)[0]) }
|
|
421
|
+
: null;
|
|
422
|
+
const type = Object.keys(ENTITY_TYPE_MAP).find(key => ENTITY_TYPE_MAP[key] === action.entity);
|
|
423
|
+
updateBase(
|
|
424
|
+
action.filePath,
|
|
425
|
+
{ Id: action.recordId, Type: type },
|
|
426
|
+
'',
|
|
427
|
+
updateSnapshot
|
|
428
|
+
);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
case "delete":
|
|
433
|
+
removeFromBase(action.recordId);
|
|
434
|
+
break;
|
|
435
|
+
|
|
436
|
+
case "delete_static_asset":
|
|
437
|
+
// Skip if already cleaned from cache during 404 handling
|
|
438
|
+
if (!operationResult?.cleanedFromCache) {
|
|
439
|
+
// Use the original base.json key if available (avoids path format mismatches)
|
|
440
|
+
if (action.baseKey) {
|
|
441
|
+
removeFromBase(action.baseKey);
|
|
442
|
+
} else {
|
|
443
|
+
for (const name of action.names) {
|
|
444
|
+
const filePath = action.filePath || `${EXPORT_ROOT}/${action.folder}/${name}`;
|
|
445
|
+
removeFromBase(filePath);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
|
|
451
|
+
case "create_static_asset":
|
|
452
|
+
updateBase(action.filePath, { Id: action.filePath, Type: "File" });
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case "create_folder":
|
|
456
|
+
updateBase(action.folderPath, { Id: action.folderPath, Type: "Folder" });
|
|
457
|
+
break;
|
|
458
|
+
|
|
459
|
+
case "delete_folder": {
|
|
460
|
+
// Skip if already cleaned from cache during 404 handling
|
|
461
|
+
if (!operationResult?.cleanedFromCache) {
|
|
462
|
+
// Remove the folder itself from base using the original base.json key
|
|
463
|
+
removeFromBase(action.baseKey || action.folderPath);
|
|
464
|
+
|
|
465
|
+
// Also remove all files and subfolders inside this folder from base
|
|
466
|
+
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
467
|
+
const cachedResults = hits?.[0]?.value || {};
|
|
468
|
+
|
|
469
|
+
for (const [recordId, cachedEntry] of Object.entries(cachedResults)) {
|
|
470
|
+
// Check all possible path fields
|
|
471
|
+
const entryPath = cachedEntry.lastKnownActualPath || cachedEntry.filePath || cachedEntry.lastKnownPath;
|
|
472
|
+
if (entryPath && typeof entryPath === 'string') {
|
|
473
|
+
const normalizedEntryPath = path.normalize(path.resolve(entryPath)).toLowerCase();
|
|
474
|
+
const normalizedFolderPath = path.normalize(path.resolve(action.folderPath)).toLowerCase();
|
|
475
|
+
|
|
476
|
+
// Check if this entry is inside the deleted folder
|
|
477
|
+
if (normalizedEntryPath.startsWith(normalizedFolderPath + path.sep) ||
|
|
478
|
+
normalizedEntryPath === normalizedFolderPath) {
|
|
479
|
+
removeFromBase(recordId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
case "create_iris_app":
|
|
488
|
+
case "update_iris_app": {
|
|
489
|
+
// Update base.json with new/updated Iris app info including content hash
|
|
490
|
+
updateBase(action.appPath, {
|
|
491
|
+
Id: `iris-app:${action.slug}`,
|
|
492
|
+
Type: 'IrisApp',
|
|
493
|
+
folderName: action.slug,
|
|
494
|
+
appName: action.appName,
|
|
495
|
+
modifiedOn: new Date().toISOString(),
|
|
496
|
+
contentHash: action.contentHash // Store hash for change detection
|
|
497
|
+
}, action.appPath);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
case "delete_iris_app": {
|
|
502
|
+
// Skip if already cleaned from cache during 404 handling
|
|
503
|
+
if (!operationResult?.cleanedFromCache) {
|
|
504
|
+
removeFromBase(`iris-app:${action.slug}`);
|
|
505
|
+
}
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.warn(chalk.yellow(`Warning: Failed to update cache for ${action.action}: ${error.message}`));
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/* ==================== NETWORK REQUEST HANDLER ==================== */
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Groups actions by resource ID to detect conflicts and sequence operations.
|
|
518
|
+
* @returns {Object} { byResource: Map<recordId, actions[]>, assets: actions[] }
|
|
519
|
+
*/
|
|
520
|
+
const groupActionsByResource = (actionQueue) => {
|
|
521
|
+
const byResource = new Map(); // recordId -> actions[]
|
|
522
|
+
const assets = []; // Asset operations (can run in parallel)
|
|
523
|
+
|
|
524
|
+
for (let i = 0; i < actionQueue.length; i++) {
|
|
525
|
+
const action = { ...actionQueue[i], originalIndex: i };
|
|
526
|
+
|
|
527
|
+
// Asset and Iris app operations don't need sequencing
|
|
528
|
+
if (['create_static_asset', 'delete_static_asset', 'create_folder', 'delete_folder', 'create_iris_app', 'update_iris_app', 'delete_iris_app'].includes(action.action)) {
|
|
529
|
+
assets.push(action);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Code entity operations - group by recordId
|
|
534
|
+
const resourceId = action.recordId;
|
|
535
|
+
if (!resourceId) {
|
|
536
|
+
// Create actions without recordId yet - treat as unique resource
|
|
537
|
+
assets.push(action);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!byResource.has(resourceId)) {
|
|
542
|
+
byResource.set(resourceId, []);
|
|
543
|
+
}
|
|
544
|
+
byResource.get(resourceId).push(action);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { byResource, assets };
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Resolves conflicts for a single resource and returns actions to execute in sequence.
|
|
552
|
+
* Rules:
|
|
553
|
+
* - If DELETE exists, drop all other actions (delete wins)
|
|
554
|
+
* - If multiple UPDATEs exist, keep them in order (will execute sequentially)
|
|
555
|
+
*/
|
|
556
|
+
const resolveResourceConflicts = (actions) => {
|
|
557
|
+
// Check if there's a delete action
|
|
558
|
+
const hasDelete = actions.some(a => a.action === 'delete');
|
|
559
|
+
|
|
560
|
+
if (hasDelete) {
|
|
561
|
+
// Delete wins - drop all other actions and only keep the delete
|
|
562
|
+
return actions.filter(a => a.action === 'delete');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// No delete - return all actions (they'll execute sequentially)
|
|
566
|
+
return actions;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Executes actions for a single resource sequentially.
|
|
571
|
+
*/
|
|
572
|
+
const executeResourceActions = async (instanceUrl, token, actions) => {
|
|
573
|
+
const results = [];
|
|
574
|
+
|
|
575
|
+
for (const action of actions) {
|
|
576
|
+
try {
|
|
577
|
+
let result;
|
|
578
|
+
switch (action.action) {
|
|
579
|
+
case "create":
|
|
580
|
+
result = await handleCreateAction(instanceUrl, token, action);
|
|
581
|
+
break;
|
|
582
|
+
case "update":
|
|
583
|
+
result = await handleUpdateAction(instanceUrl, token, action);
|
|
584
|
+
break;
|
|
585
|
+
case "delete":
|
|
586
|
+
result = await handleDeleteAction(instanceUrl, token, action);
|
|
587
|
+
break;
|
|
588
|
+
default:
|
|
589
|
+
throw new Error(`Unknown action: ${action.action}`);
|
|
590
|
+
}
|
|
591
|
+
results.push({ index: action.originalIndex, action, result, success: true });
|
|
592
|
+
} catch (error) {
|
|
593
|
+
results.push({ index: action.originalIndex, action, error: error.message, success: false });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return results;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Executes a single action and returns the result.
|
|
602
|
+
*/
|
|
603
|
+
const executeAction = async (instanceUrl, token, action) => {
|
|
604
|
+
let result;
|
|
605
|
+
switch (action.action) {
|
|
606
|
+
case "create":
|
|
607
|
+
result = await handleCreateAction(instanceUrl, token, action);
|
|
608
|
+
break;
|
|
609
|
+
case "update":
|
|
610
|
+
result = await handleUpdateAction(instanceUrl, token, action);
|
|
611
|
+
break;
|
|
612
|
+
case "delete":
|
|
613
|
+
result = await handleDeleteAction(instanceUrl, token, action);
|
|
614
|
+
break;
|
|
615
|
+
case "create_static_asset":
|
|
616
|
+
result = await handleCreateStaticAssetAction(instanceUrl, token, action);
|
|
617
|
+
break;
|
|
618
|
+
case "delete_static_asset":
|
|
619
|
+
result = await handleDeleteStaticAssetAction(instanceUrl, token, action);
|
|
620
|
+
break;
|
|
621
|
+
case "create_folder":
|
|
622
|
+
result = await handleCreateFolderAction(instanceUrl, token, action);
|
|
623
|
+
break;
|
|
624
|
+
case "delete_folder":
|
|
625
|
+
result = await handleDeleteFolderAction(instanceUrl, token, action);
|
|
626
|
+
break;
|
|
627
|
+
case "create_iris_app":
|
|
628
|
+
case "update_iris_app":
|
|
629
|
+
result = await handlePublishIrisAppAction(instanceUrl, token, action);
|
|
630
|
+
break;
|
|
631
|
+
case "delete_iris_app":
|
|
632
|
+
result = await handleDeleteIrisAppAction(instanceUrl, token, action);
|
|
633
|
+
break;
|
|
634
|
+
default:
|
|
635
|
+
throw new Error(`Unknown action: ${action.action}`);
|
|
636
|
+
}
|
|
637
|
+
return result;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Executes all actions sequentially (one at a time) with progress messages.
|
|
642
|
+
*/
|
|
643
|
+
const performNetworkRequestSequential = async (actionQueue) => {
|
|
644
|
+
const { instanceUrl, token } = await ensureValidCredentials();
|
|
645
|
+
|
|
646
|
+
console.log(chalk.blue(`\n🔄 Sequential processing mode (${actionQueue.length} operations)\n`));
|
|
647
|
+
|
|
648
|
+
const results = [];
|
|
649
|
+
let successCount = 0;
|
|
650
|
+
let errorCount = 0;
|
|
651
|
+
|
|
652
|
+
for (let i = 0; i < actionQueue.length; i++) {
|
|
653
|
+
const action = { ...actionQueue[i], originalIndex: i };
|
|
654
|
+
const displayName = getActionDisplayName(action);
|
|
655
|
+
|
|
656
|
+
// Show progress message
|
|
657
|
+
console.log(chalk.gray(`[${i + 1}/${actionQueue.length}] Processing ${action.action.toUpperCase()} ${displayName}...`));
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const result = await executeAction(instanceUrl, token.value, action);
|
|
661
|
+
results.push({ index: i, action, result, success: true });
|
|
662
|
+
|
|
663
|
+
successCount++;
|
|
664
|
+
console.log(
|
|
665
|
+
chalk.green(`✓ [${i + 1}]`) +
|
|
666
|
+
` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
|
|
667
|
+
(result?.recordId ? chalk.magenta(result.recordId) : "")
|
|
668
|
+
);
|
|
669
|
+
await updateCacheAfterSuccess(action, result);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
results.push({ index: i, action, error: error.message, success: false });
|
|
672
|
+
|
|
673
|
+
errorCount++;
|
|
674
|
+
console.log();
|
|
675
|
+
console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
|
|
676
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
677
|
+
const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
|
|
678
|
+
console.log(chalk.red.bold(`[${i + 1}] ${action.action.toUpperCase()} ${displayName} (${actionPath}):`));
|
|
679
|
+
console.log(formatMultilineError(error.message));
|
|
680
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
console.log(); // Add spacing between operations
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Summary
|
|
687
|
+
console.log(chalk.blue("--- Publish Summary ---"));
|
|
688
|
+
console.log(chalk.green(`✓ Successful: ${successCount}`));
|
|
689
|
+
if (errorCount > 0) {
|
|
690
|
+
console.log(chalk.red(`✗ Failed: ${errorCount}`));
|
|
691
|
+
} else {
|
|
692
|
+
console.log(chalk.green("All operations completed successfully! 🎉"));
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Executes all actions in the queue with proper sequencing and conflict resolution (parallel mode).
|
|
698
|
+
*/
|
|
699
|
+
const performNetworkRequestParallel = async (actionQueue) => {
|
|
700
|
+
const { instanceUrl, token } = await ensureValidCredentials();
|
|
701
|
+
|
|
702
|
+
// Group actions by resource
|
|
703
|
+
const { byResource, assets } = groupActionsByResource(actionQueue);
|
|
704
|
+
|
|
705
|
+
// Resolve conflicts for each resource
|
|
706
|
+
const sequencedCodeActions = [];
|
|
707
|
+
const droppedActions = [];
|
|
708
|
+
|
|
709
|
+
for (const [resourceId, actions] of byResource.entries()) {
|
|
710
|
+
const resolved = resolveResourceConflicts(actions);
|
|
711
|
+
sequencedCodeActions.push(resolved);
|
|
712
|
+
|
|
713
|
+
// Track dropped actions
|
|
714
|
+
const droppedCount = actions.length - resolved.length;
|
|
715
|
+
if (droppedCount > 0) {
|
|
716
|
+
const dropped = actions.filter(a => !resolved.includes(a));
|
|
717
|
+
droppedActions.push(...dropped);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Log dropped actions
|
|
722
|
+
if (droppedActions.length > 0) {
|
|
723
|
+
console.log(chalk.yellow(`\n⚠️ Dropped ${droppedActions.length} redundant operation(s) due to delete:`));
|
|
724
|
+
droppedActions.forEach(action => {
|
|
725
|
+
console.log(chalk.gray(` • ${action.action.toUpperCase()} on ${getActionDisplayName(action)} (superseded by DELETE)`));
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Execute asset operations in parallel (they don't conflict)
|
|
730
|
+
const assetPromises = assets.map(async (action) => {
|
|
731
|
+
try {
|
|
732
|
+
const result = await executeAction(instanceUrl, token.value, action);
|
|
733
|
+
return { index: action.originalIndex, action, result, success: true };
|
|
734
|
+
} catch (error) {
|
|
735
|
+
return { index: action.originalIndex, action, error: error.message, success: false };
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// Execute code entity operations sequentially per resource (but resources in parallel)
|
|
740
|
+
const codePromises = sequencedCodeActions.map(actions =>
|
|
741
|
+
executeResourceActions(instanceUrl, token.value, actions)
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Wait for all operations
|
|
745
|
+
const assetResults = await Promise.allSettled(assetPromises);
|
|
746
|
+
const codeResults = await Promise.allSettled(codePromises);
|
|
747
|
+
|
|
748
|
+
// Flatten code results
|
|
749
|
+
const allCodeResults = codeResults
|
|
750
|
+
.filter(r => r.status === 'fulfilled')
|
|
751
|
+
.flatMap(r => r.value);
|
|
752
|
+
|
|
753
|
+
const allAssetResults = assetResults
|
|
754
|
+
.map(r => r.status === 'fulfilled' ? r.value : { status: 'rejected', reason: r.reason });
|
|
755
|
+
|
|
756
|
+
// Combine all results
|
|
757
|
+
const allResults = [...allCodeResults, ...allAssetResults];
|
|
758
|
+
|
|
759
|
+
// Sort by original index to maintain order
|
|
760
|
+
allResults.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
761
|
+
|
|
762
|
+
// Process and display results
|
|
763
|
+
let successCount = 0;
|
|
764
|
+
let errorCount = 0;
|
|
765
|
+
|
|
766
|
+
for (const result of allResults) {
|
|
767
|
+
if (result.success !== undefined) {
|
|
768
|
+
const { index, action, success, error, result: operationResult } = result;
|
|
769
|
+
|
|
770
|
+
if (success) {
|
|
771
|
+
successCount++;
|
|
772
|
+
const displayName = getActionDisplayName(action);
|
|
773
|
+
console.log(
|
|
774
|
+
chalk.green(`✓ [${index + 1}]`) +
|
|
775
|
+
` ${chalk.yellow(action.action.toUpperCase())} ${chalk.cyan(displayName)} ` +
|
|
776
|
+
(operationResult?.recordId ? chalk.magenta(operationResult.recordId) : "")
|
|
777
|
+
);
|
|
778
|
+
await updateCacheAfterSuccess(action, operationResult);
|
|
779
|
+
} else {
|
|
780
|
+
errorCount++;
|
|
781
|
+
console.log();
|
|
782
|
+
console.log(chalk.bgRed.bold.white(' ✖ Operation Failed '));
|
|
783
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
784
|
+
const actionPath = action.filePath || action.folderPath || action.folder || action.appPath || action.slug || 'unknown';
|
|
785
|
+
console.log(chalk.red.bold(`[${index + 1}] ${action.action.toUpperCase()} ${getActionDisplayName(action)} (${actionPath}):`));
|
|
786
|
+
console.log(formatMultilineError(error));
|
|
787
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
788
|
+
}
|
|
789
|
+
} else if (result.status === 'rejected') {
|
|
790
|
+
errorCount++;
|
|
791
|
+
console.log();
|
|
792
|
+
console.log(chalk.bgRed.bold.white(' ✖ Unexpected Error '));
|
|
793
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
794
|
+
console.log(`${chalk.redBright(' •')} ${chalk.whiteBright(result.reason)}`);
|
|
795
|
+
console.log(chalk.redBright('─'.repeat(48)));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Summary
|
|
800
|
+
console.log(chalk.blue("\n--- Publish Summary ---"));
|
|
801
|
+
console.log(chalk.green(`✓ Successful: ${successCount}`));
|
|
802
|
+
if (droppedActions.length > 0) {
|
|
803
|
+
console.log(chalk.yellow(`⊝ Dropped: ${droppedActions.length} (redundant)`));
|
|
804
|
+
}
|
|
805
|
+
if (errorCount > 0) {
|
|
806
|
+
console.log(chalk.red(`✗ Failed: ${errorCount}`));
|
|
807
|
+
} else {
|
|
808
|
+
console.log(chalk.green("All operations completed successfully! 🎉"));
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Executes all actions in the queue (dispatcher function).
|
|
814
|
+
*/
|
|
815
|
+
const performNetworkRequest = async (actionQueue) => {
|
|
816
|
+
if (USE_SEQUENTIAL_PROCESSING) {
|
|
817
|
+
return await performNetworkRequestSequential(actionQueue);
|
|
818
|
+
} else {
|
|
819
|
+
return await performNetworkRequestParallel(actionQueue);
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
/* ==================== MAIN PUBLISH LOGIC ==================== */
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Core publish logic - can be called from autopublish or directly.
|
|
827
|
+
* @param {Object} options - Configuration options
|
|
828
|
+
* @param {boolean} options.silent - If true, suppress summary output
|
|
829
|
+
* @returns {Promise<{actionQueue: Array, hasChanges: boolean}>}
|
|
830
|
+
*/
|
|
831
|
+
export const runPublish = async (options = {}) => {
|
|
832
|
+
const { silent = false } = options;
|
|
833
|
+
|
|
834
|
+
// Create progress tracker
|
|
835
|
+
const progress = silent ? null : new ProgressTracker('Publish to Magentrix');
|
|
836
|
+
if (progress) {
|
|
837
|
+
progress.addStep('auth', 'Authenticating...');
|
|
838
|
+
progress.addStep('load', 'Loading cached data...');
|
|
839
|
+
progress.addStep('scan', 'Scanning local files...');
|
|
840
|
+
progress.addStep('compare-assets', 'Comparing assets...', { hasProgress: true });
|
|
841
|
+
progress.addStep('compare-code', 'Comparing code entities...', { hasProgress: true });
|
|
842
|
+
progress.addStep('prepare', 'Preparing action queue...');
|
|
843
|
+
progress.start();
|
|
844
|
+
// Start first step immediately so UI shows up
|
|
845
|
+
progress.startStep('auth');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Step 1: Authenticate
|
|
849
|
+
await ensureValidCredentials().catch((err) => {
|
|
850
|
+
if (progress) {
|
|
851
|
+
progress.abort(err.message);
|
|
852
|
+
} else if (!silent) {
|
|
853
|
+
console.error(chalk.red.bold("Authentication failed:"), chalk.white(err.message));
|
|
854
|
+
}
|
|
855
|
+
throw err;
|
|
856
|
+
});
|
|
857
|
+
if (progress) progress.completeStep('auth', '✓ Authenticated');
|
|
858
|
+
|
|
859
|
+
// Step 2: Load cached file state
|
|
860
|
+
if (progress) progress.startStep('load');
|
|
861
|
+
|
|
862
|
+
const loadStart = Date.now();
|
|
863
|
+
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
864
|
+
const cachedResults = hits?.[0]?.value || {};
|
|
865
|
+
const loadTime = Date.now() - loadStart;
|
|
866
|
+
|
|
867
|
+
if (!Object.keys(cachedResults).length) {
|
|
868
|
+
if (progress) {
|
|
869
|
+
progress.abort('No file cache found');
|
|
870
|
+
} else if (!silent) {
|
|
871
|
+
console.log(chalk.red.bold("No file cache found!"));
|
|
872
|
+
console.log(`Run ${chalk.cyan("magentrix pull")} to initialize your workspace.`);
|
|
873
|
+
}
|
|
874
|
+
throw new Error("No file cache found");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const mapStart = Date.now();
|
|
878
|
+
const cachedFiles = Object.values(cachedResults).map((c) => ({
|
|
879
|
+
...c,
|
|
880
|
+
tag: c.recordId,
|
|
881
|
+
filePath: c.filePath || c.lastKnownPath,
|
|
882
|
+
type: c.type || c.Type, // Normalize Type/type property
|
|
883
|
+
}));
|
|
884
|
+
const mapTime = Date.now() - mapStart;
|
|
885
|
+
|
|
886
|
+
if (progress) {
|
|
887
|
+
progress.completeStep('load', `✓ Loaded ${cachedFiles.length} entries (${loadTime}ms load, ${mapTime}ms map)`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Step 3: Scan local workspace (only whitelisted code entity directories)
|
|
891
|
+
if (progress) progress.startStep('scan');
|
|
892
|
+
|
|
893
|
+
const walkStart = Date.now();
|
|
894
|
+
// Only scan whitelisted directories for code entities (exclude Assets and iris-apps, handled separately)
|
|
895
|
+
const codeEntityDirs = ALLOWED_SRC_DIRS.filter(dir => dir !== 'Assets' && dir !== IRIS_APPS_DIR);
|
|
896
|
+
const localPathArrays = await Promise.all(
|
|
897
|
+
codeEntityDirs.map(dir => {
|
|
898
|
+
const dirPath = path.join(EXPORT_ROOT, dir);
|
|
899
|
+
return fs.existsSync(dirPath) ? walkFiles(dirPath) : Promise.resolve([]);
|
|
900
|
+
})
|
|
901
|
+
);
|
|
902
|
+
const localPaths = localPathArrays.flat();
|
|
903
|
+
const walkTime = Date.now() - walkStart;
|
|
904
|
+
|
|
905
|
+
const tagStart = Date.now();
|
|
906
|
+
const localFiles = await Promise.all(
|
|
907
|
+
localPaths.map(async (p) => {
|
|
908
|
+
try {
|
|
909
|
+
const tag = await getFileTag(p);
|
|
910
|
+
return { tag, path: p };
|
|
911
|
+
} catch {
|
|
912
|
+
return { tag: null, path: p };
|
|
913
|
+
}
|
|
914
|
+
})
|
|
915
|
+
);
|
|
916
|
+
const tagTime = Date.now() - tagStart;
|
|
917
|
+
|
|
918
|
+
if (progress) {
|
|
919
|
+
progress.completeStep('scan', `✓ Found ${localPaths.length} files (${walkTime}ms walk, ${tagTime}ms tags)`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Step 4: Create lookup maps
|
|
923
|
+
if (progress) progress.startStep('compare-assets');
|
|
924
|
+
|
|
925
|
+
const mapBuildStart = Date.now();
|
|
926
|
+
const cacheById = Object.fromEntries(cachedFiles.map((c) => [c.tag, c]));
|
|
927
|
+
const localById = Object.fromEntries(localFiles.filter((f) => f.tag).map((f) => [f.tag, f]));
|
|
928
|
+
const newLocalNoId = localFiles.filter((f) => !f.tag);
|
|
929
|
+
const allIds = new Set([...Object.keys(cacheById), ...Object.keys(localById)]);
|
|
930
|
+
const mapBuildTime = Date.now() - mapBuildStart;
|
|
931
|
+
|
|
932
|
+
const actionQueue = [];
|
|
933
|
+
|
|
934
|
+
// Step 5: Handle static asset files - Build fast lookup map first (O(n) instead of O(n²))
|
|
935
|
+
const assetWalkStart = Date.now();
|
|
936
|
+
const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
|
|
937
|
+
const assetWalkTime = Date.now() - assetWalkStart;
|
|
938
|
+
|
|
939
|
+
// Build a Set of normalized cached asset paths for O(1) lookup
|
|
940
|
+
const setStart = Date.now();
|
|
941
|
+
const cachedAssetPaths = new Set();
|
|
942
|
+
cachedFiles
|
|
943
|
+
.filter(cf => cf.type === 'File' || cf.type === 'Folder')
|
|
944
|
+
.forEach(cf => {
|
|
945
|
+
if (cf.lastKnownActualPath) {
|
|
946
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
|
|
947
|
+
}
|
|
948
|
+
if (cf.filePath) {
|
|
949
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
|
|
950
|
+
}
|
|
951
|
+
if (cf.lastKnownPath) {
|
|
952
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
const setTime = Date.now() - setStart;
|
|
956
|
+
|
|
957
|
+
// Now compare assets with O(1) lookup
|
|
958
|
+
for (let i = 0; i < assetPaths.length; i++) {
|
|
959
|
+
const assetPath = assetPaths[i];
|
|
960
|
+
const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
|
|
961
|
+
|
|
962
|
+
// Update progress every 100 files
|
|
963
|
+
if (progress && i % 100 === 0) {
|
|
964
|
+
progress.updateProgress('compare-assets', i, assetPaths.length, `Checking ${i}/${assetPaths.length} assets`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// O(1) lookup instead of O(n) find()
|
|
968
|
+
if (cachedAssetPaths.has(normalizedAssetPath)) {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
actionQueue.push({
|
|
973
|
+
action: "create_static_asset",
|
|
974
|
+
folder: toApiPath(assetPath),
|
|
975
|
+
filePath: assetPath
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (progress) {
|
|
980
|
+
progress.completeStep('compare-assets', `✓ Compared ${assetPaths.length} assets (walk:${assetWalkTime}ms, set:${setTime}ms, map:${mapBuildTime}ms)`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Step 6: Handle folder creation and deletion - Also optimized with Set
|
|
984
|
+
const assetsDir = path.join(EXPORT_ROOT, 'Assets');
|
|
985
|
+
if (fs.existsSync(assetsDir)) {
|
|
986
|
+
const localFolders = walkFolders(assetsDir);
|
|
987
|
+
const cachedFolders = cachedFiles
|
|
988
|
+
.filter(c => c.type === 'Folder' && (c.filePath || c.lastKnownPath || c.lastKnownActualPath));
|
|
989
|
+
|
|
990
|
+
// Build Set of cached folder paths for O(1) lookup
|
|
991
|
+
const cachedFolderPaths = new Set();
|
|
992
|
+
cachedFolders.forEach(cf => {
|
|
993
|
+
if (cf.lastKnownActualPath) {
|
|
994
|
+
cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
|
|
995
|
+
}
|
|
996
|
+
if (cf.lastKnownPath) {
|
|
997
|
+
cachedFolderPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
|
|
998
|
+
}
|
|
999
|
+
if (cf.filePath) {
|
|
1000
|
+
cachedFolderPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// Build Set of local folder paths for O(1) lookup
|
|
1005
|
+
const localFolderPaths = new Set(
|
|
1006
|
+
localFolders.map(lf => path.normalize(path.resolve(lf)).toLowerCase())
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
// New folders - O(1) lookup
|
|
1010
|
+
for (const folderPath of localFolders) {
|
|
1011
|
+
if (!folderPath) continue;
|
|
1012
|
+
|
|
1013
|
+
const normalizedFolderPath = path.normalize(path.resolve(folderPath)).toLowerCase();
|
|
1014
|
+
|
|
1015
|
+
if (cachedFolderPaths.has(normalizedFolderPath)) {
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const parentDir = path.dirname(folderPath);
|
|
1020
|
+
if (!parentDir || parentDir === '.' || parentDir === folderPath) continue;
|
|
1021
|
+
|
|
1022
|
+
actionQueue.push({
|
|
1023
|
+
action: "create_folder",
|
|
1024
|
+
folderPath: folderPath,
|
|
1025
|
+
parentPath: toApiFolderPath(parentDir),
|
|
1026
|
+
folderName: path.basename(folderPath)
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Deleted folders - O(1) lookup
|
|
1031
|
+
for (const cachedFolder of cachedFolders) {
|
|
1032
|
+
const cachedPath = cachedFolder.lastKnownActualPath || cachedFolder.lastKnownPath || cachedFolder.filePath;
|
|
1033
|
+
if (!cachedPath || typeof cachedPath !== 'string') continue;
|
|
1034
|
+
|
|
1035
|
+
const normalizedCachedPath = path.normalize(path.resolve(cachedPath)).toLowerCase();
|
|
1036
|
+
|
|
1037
|
+
if (localFolderPaths.has(normalizedCachedPath)) {
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const parentDir = path.dirname(cachedPath);
|
|
1042
|
+
if (!parentDir || parentDir === '.' || parentDir === cachedPath) continue;
|
|
1043
|
+
|
|
1044
|
+
actionQueue.push({
|
|
1045
|
+
action: "delete_folder",
|
|
1046
|
+
folderPath: cachedPath,
|
|
1047
|
+
parentPath: toApiFolderPath(parentDir),
|
|
1048
|
+
folderName: path.basename(cachedPath),
|
|
1049
|
+
baseKey: cachedFolder.tag // The original base.json key for correct cache cleanup
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Step 7: Process code entities (ActiveClass/ActivePage) and static assets
|
|
1055
|
+
if (progress) progress.startStep('compare-code');
|
|
1056
|
+
|
|
1057
|
+
const allIdsArray = Array.from(allIds);
|
|
1058
|
+
for (let idx = 0; idx < allIdsArray.length; idx++) {
|
|
1059
|
+
const id = allIdsArray[idx];
|
|
1060
|
+
|
|
1061
|
+
// Update progress every 50 items
|
|
1062
|
+
if (progress && idx % 50 === 0) {
|
|
1063
|
+
progress.updateProgress('compare-code', idx, allIdsArray.length, `Checking ${idx}/${allIdsArray.length} entities`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
const cacheFile = cacheById[id];
|
|
1068
|
+
const curFile = localById[id];
|
|
1069
|
+
|
|
1070
|
+
// Skip folders - they're handled separately
|
|
1071
|
+
if (cacheFile?.type === 'Folder') continue;
|
|
1072
|
+
|
|
1073
|
+
// Skip Iris apps - they're handled separately (check both type and ID prefix)
|
|
1074
|
+
if (cacheFile?.type === 'IrisApp' || id.startsWith('iris-app:')) continue;
|
|
1075
|
+
|
|
1076
|
+
// Handle static asset files
|
|
1077
|
+
if (cacheFile?.type === 'File') {
|
|
1078
|
+
// Use lastKnownActualPath which has the correct path (e.g., "src/Assets/...")
|
|
1079
|
+
const actualPath = cacheFile.lastKnownActualPath || cacheFile.filePath;
|
|
1080
|
+
const localAssetExists = fs.existsSync(actualPath);
|
|
1081
|
+
|
|
1082
|
+
if (!localAssetExists) {
|
|
1083
|
+
actionQueue.push({
|
|
1084
|
+
action: 'delete_static_asset',
|
|
1085
|
+
folder: toApiPath(actualPath),
|
|
1086
|
+
names: [path.basename(actualPath)],
|
|
1087
|
+
filePath: actualPath, // Store actual file path for filtering
|
|
1088
|
+
baseKey: id // The original base.json key for correct cache cleanup
|
|
1089
|
+
});
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const contentHash = sha256(fs.readFileSync(actualPath, 'utf-8'));
|
|
1094
|
+
if (contentHash !== cacheFile.contentHash) {
|
|
1095
|
+
actionQueue.push({
|
|
1096
|
+
action: "create_static_asset",
|
|
1097
|
+
folder: toApiPath(actualPath),
|
|
1098
|
+
filePath: actualPath
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Handle code entity deletion
|
|
1105
|
+
if (cacheFile && !curFile) {
|
|
1106
|
+
const { type, entity } = inferMeta(path.resolve(cacheFile.lastKnownPath));
|
|
1107
|
+
actionQueue.push({
|
|
1108
|
+
action: "delete",
|
|
1109
|
+
recordId: id,
|
|
1110
|
+
filePath: cacheFile.lastKnownPath,
|
|
1111
|
+
type,
|
|
1112
|
+
entity,
|
|
1113
|
+
});
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Handle code entity creation
|
|
1118
|
+
if (!cacheFile && curFile) {
|
|
1119
|
+
const safe = readFileSafe(curFile.path);
|
|
1120
|
+
if (!safe) continue;
|
|
1121
|
+
|
|
1122
|
+
const { content, hash } = safe;
|
|
1123
|
+
const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
|
|
1124
|
+
|
|
1125
|
+
actionQueue.push({
|
|
1126
|
+
action: "create",
|
|
1127
|
+
filePath: curFile.path,
|
|
1128
|
+
recordId: id || null,
|
|
1129
|
+
type,
|
|
1130
|
+
entity,
|
|
1131
|
+
fields: { [contentField]: content },
|
|
1132
|
+
contentHash: hash,
|
|
1133
|
+
});
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Handle code entity update
|
|
1138
|
+
if (cacheFile && curFile) {
|
|
1139
|
+
const safe = readFileSafe(curFile.path);
|
|
1140
|
+
if (!safe) continue;
|
|
1141
|
+
|
|
1142
|
+
const { content, hash } = safe;
|
|
1143
|
+
// Check both paths - only consider renamed if NEITHER matches current path
|
|
1144
|
+
// This prevents false positives from stale/corrupted tracking data
|
|
1145
|
+
const resolvedCurPath = path.resolve(curFile.path);
|
|
1146
|
+
const matchesActualPath = cacheFile.lastKnownActualPath === resolvedCurPath;
|
|
1147
|
+
const matchesExpectedPath = cacheFile.lastKnownPath === resolvedCurPath;
|
|
1148
|
+
const renamed = !matchesActualPath && !matchesExpectedPath;
|
|
1149
|
+
const contentChanged = hash !== cacheFile.contentHash;
|
|
1150
|
+
|
|
1151
|
+
if (renamed || contentChanged) {
|
|
1152
|
+
const { type, entity, contentField } = inferMeta(path.resolve(curFile.path));
|
|
1153
|
+
|
|
1154
|
+
actionQueue.push({
|
|
1155
|
+
action: "update",
|
|
1156
|
+
recordId: id,
|
|
1157
|
+
type,
|
|
1158
|
+
entity,
|
|
1159
|
+
fields: { [contentField]: content },
|
|
1160
|
+
renamed,
|
|
1161
|
+
oldPath: cacheFile.lastKnownActualPath || cacheFile.lastKnownPath,
|
|
1162
|
+
filePath: curFile.path,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
if (!silent) {
|
|
1168
|
+
console.error(chalk.yellow(`Warning: Error processing file with ID ${id}:`), err.message);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (progress) {
|
|
1174
|
+
progress.completeStep('compare-code', `✓ Compared ${allIdsArray.length} code entities`);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Step 7b: Scan and compare Iris apps
|
|
1178
|
+
const irisAppsPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR);
|
|
1179
|
+
|
|
1180
|
+
// Get cached Iris apps (always check, even if local folder doesn't exist)
|
|
1181
|
+
// Also include entries with iris-app: prefix in case type wasn't properly set
|
|
1182
|
+
const cachedIrisApps = cachedFiles
|
|
1183
|
+
.filter(cf => cf.type === 'IrisApp' || (cf.tag && cf.tag.startsWith('iris-app:')))
|
|
1184
|
+
.map(cf => {
|
|
1185
|
+
// Extract slug from folderName, or from the ID (iris-app:<slug>)
|
|
1186
|
+
const slug = cf.folderName || (cf.tag && cf.tag.startsWith('iris-app:') ? cf.tag.replace('iris-app:', '') : null);
|
|
1187
|
+
return {
|
|
1188
|
+
slug,
|
|
1189
|
+
appName: cf.appName || slug,
|
|
1190
|
+
modifiedOn: cf.modifiedOn,
|
|
1191
|
+
contentHash: cf.contentHash || null // Track content hash for change detection
|
|
1192
|
+
};
|
|
1193
|
+
})
|
|
1194
|
+
.filter(app => app.slug); // Filter out any entries without a valid slug
|
|
1195
|
+
|
|
1196
|
+
// Get local Iris apps (empty if folder doesn't exist)
|
|
1197
|
+
const localIrisApps = fs.existsSync(irisAppsPath)
|
|
1198
|
+
? fs.readdirSync(irisAppsPath, { withFileTypes: true })
|
|
1199
|
+
.filter(d => d.isDirectory())
|
|
1200
|
+
.map(d => d.name)
|
|
1201
|
+
: [];
|
|
1202
|
+
|
|
1203
|
+
const cachedIrisSlugs = new Set(cachedIrisApps.map(a => a.slug));
|
|
1204
|
+
const localIrisSlugs = new Set(localIrisApps);
|
|
1205
|
+
|
|
1206
|
+
// Detect new and modified Iris apps
|
|
1207
|
+
for (const slug of localIrisApps) {
|
|
1208
|
+
const appPath = path.join(irisAppsPath, slug);
|
|
1209
|
+
|
|
1210
|
+
// Validate the app folder has required files
|
|
1211
|
+
const validation = validateIrisAppFolder(appPath);
|
|
1212
|
+
if (!validation.valid) {
|
|
1213
|
+
if (!silent) {
|
|
1214
|
+
console.log(chalk.yellow(`⚠ Skipping invalid Iris app '${slug}': ${validation.errors.join(', ')}`));
|
|
1215
|
+
}
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Get app metadata from linked project config (stored globally) or use slug
|
|
1220
|
+
const linkedProjects = getLinkedProjects();
|
|
1221
|
+
const linkedProject = linkedProjects.find(p => p.slug === slug);
|
|
1222
|
+
const appName = linkedProject?.appName || slug;
|
|
1223
|
+
const appDescription = linkedProject?.appDescription || null;
|
|
1224
|
+
const appIconId = linkedProject?.appIconId || null;
|
|
1225
|
+
|
|
1226
|
+
// Calculate content hash for change detection
|
|
1227
|
+
const currentHash = hashIrisAppFolder(appPath);
|
|
1228
|
+
|
|
1229
|
+
// Check if this is a new or existing app
|
|
1230
|
+
const isNew = !cachedIrisSlugs.has(slug);
|
|
1231
|
+
|
|
1232
|
+
if (isNew) {
|
|
1233
|
+
actionQueue.push({
|
|
1234
|
+
action: 'create_iris_app',
|
|
1235
|
+
slug,
|
|
1236
|
+
appName,
|
|
1237
|
+
appDescription,
|
|
1238
|
+
appIconId,
|
|
1239
|
+
appPath,
|
|
1240
|
+
contentHash: currentHash
|
|
1241
|
+
});
|
|
1242
|
+
} else {
|
|
1243
|
+
// Only update if content has changed
|
|
1244
|
+
const cachedApp = cachedIrisApps.find(a => a.slug === slug);
|
|
1245
|
+
const contentChanged = !cachedApp?.contentHash || cachedApp.contentHash !== currentHash;
|
|
1246
|
+
|
|
1247
|
+
if (contentChanged) {
|
|
1248
|
+
actionQueue.push({
|
|
1249
|
+
action: 'update_iris_app',
|
|
1250
|
+
slug,
|
|
1251
|
+
appName: linkedProject?.appName || cachedApp?.appName || slug,
|
|
1252
|
+
appDescription,
|
|
1253
|
+
appIconId,
|
|
1254
|
+
appPath,
|
|
1255
|
+
contentHash: currentHash
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Detect deleted Iris apps (works even if iris-apps folder doesn't exist)
|
|
1262
|
+
for (const cachedApp of cachedIrisApps) {
|
|
1263
|
+
if (!localIrisSlugs.has(cachedApp.slug)) {
|
|
1264
|
+
actionQueue.push({
|
|
1265
|
+
action: 'delete_iris_app',
|
|
1266
|
+
slug: cachedApp.slug
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Step 8: Handle brand-new, tag-less files
|
|
1272
|
+
if (progress) progress.startStep('prepare');
|
|
1273
|
+
|
|
1274
|
+
for (const f of newLocalNoId) {
|
|
1275
|
+
const safe = readFileSafe(f.path);
|
|
1276
|
+
if (!safe) {
|
|
1277
|
+
if (!silent) {
|
|
1278
|
+
console.log(chalk.yellow(`Skipping unreadable file: ${f.path}`));
|
|
1279
|
+
}
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const { content, hash } = safe;
|
|
1284
|
+
const { type, entity, contentField } = inferMeta(path.resolve(f.path));
|
|
1285
|
+
|
|
1286
|
+
actionQueue.push({
|
|
1287
|
+
action: "create",
|
|
1288
|
+
filePath: f.path,
|
|
1289
|
+
recordId: null,
|
|
1290
|
+
type,
|
|
1291
|
+
entity,
|
|
1292
|
+
fields: { [contentField]: content },
|
|
1293
|
+
contentHash: hash,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Step 9: Filter out redundant file and folder deletions
|
|
1298
|
+
const foldersBeingDeleted = actionQueue
|
|
1299
|
+
.filter(a => a.action === 'delete_folder')
|
|
1300
|
+
.map(a => a.folderPath);
|
|
1301
|
+
|
|
1302
|
+
const filteredActionQueue = actionQueue.filter(action => {
|
|
1303
|
+
// Filter out files inside folders being deleted
|
|
1304
|
+
if (action.action === 'delete_static_asset' && action.filePath) {
|
|
1305
|
+
const fileDir = path.dirname(action.filePath);
|
|
1306
|
+
for (const deletedFolder of foldersBeingDeleted) {
|
|
1307
|
+
const normalizedFileDir = path.normalize(fileDir);
|
|
1308
|
+
const normalizedDeletedFolder = path.normalize(deletedFolder);
|
|
1309
|
+
|
|
1310
|
+
if (normalizedFileDir === normalizedDeletedFolder ||
|
|
1311
|
+
normalizedFileDir.startsWith(normalizedDeletedFolder + path.sep)) {
|
|
1312
|
+
return false; // Skip - covered by folder deletion
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Filter out child folders inside folders being deleted
|
|
1318
|
+
if (action.action === 'delete_folder' && action.folderPath) {
|
|
1319
|
+
for (const deletedFolder of foldersBeingDeleted) {
|
|
1320
|
+
// Don't compare a folder to itself
|
|
1321
|
+
if (action.folderPath === deletedFolder) continue;
|
|
1322
|
+
|
|
1323
|
+
const normalizedChildFolder = path.normalize(action.folderPath);
|
|
1324
|
+
const normalizedParentFolder = path.normalize(deletedFolder);
|
|
1325
|
+
|
|
1326
|
+
// Check if this folder is inside another folder being deleted
|
|
1327
|
+
if (normalizedChildFolder.startsWith(normalizedParentFolder + path.sep)) {
|
|
1328
|
+
return false; // Skip - covered by parent folder deletion
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return true;
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
if (progress) {
|
|
1337
|
+
progress.completeStep('prepare', `✓ Prepared ${filteredActionQueue.length} actions`);
|
|
1338
|
+
progress.finish();
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Step 10: Display and execute action queue
|
|
1342
|
+
if (!silent) {
|
|
1343
|
+
console.log(chalk.blue("\n--- Publish Action Queue ---"));
|
|
1344
|
+
if (!filteredActionQueue.length) {
|
|
1345
|
+
console.log(chalk.green("All files are in sync — nothing to publish!"));
|
|
1346
|
+
} else {
|
|
1347
|
+
filteredActionQueue.forEach((a, i) => {
|
|
1348
|
+
const num = chalk.green(`[${i + 1}]`);
|
|
1349
|
+
const act = chalk.yellow(a.action.toUpperCase());
|
|
1350
|
+
|
|
1351
|
+
let type, displayPath, label;
|
|
1352
|
+
if (a.slug) {
|
|
1353
|
+
// Iris app action
|
|
1354
|
+
type = chalk.cyan(`${a.appName || a.slug}`);
|
|
1355
|
+
displayPath = a.appPath || a.slug;
|
|
1356
|
+
label = "App";
|
|
1357
|
+
} else if (a.folderName) {
|
|
1358
|
+
type = chalk.cyan(a.folderName);
|
|
1359
|
+
displayPath = a.folderPath;
|
|
1360
|
+
label = "Folder";
|
|
1361
|
+
} else if (a.names) {
|
|
1362
|
+
type = chalk.cyan(a.names.join(", "));
|
|
1363
|
+
displayPath = a.folder;
|
|
1364
|
+
label = "File";
|
|
1365
|
+
} else if (a.filePath) {
|
|
1366
|
+
type = chalk.cyan(a.type || path.basename(a.filePath));
|
|
1367
|
+
displayPath = a.filePath;
|
|
1368
|
+
label = a.type ? "Type" : "File";
|
|
1369
|
+
} else {
|
|
1370
|
+
type = chalk.cyan(a.type || "Unknown");
|
|
1371
|
+
displayPath = a.folder || "Unknown";
|
|
1372
|
+
label = "File";
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const idInfo = a.recordId ? ` ${chalk.magenta(a.recordId)}` : "";
|
|
1376
|
+
const renameInfo = a.renamed
|
|
1377
|
+
? ` → ${chalk.gray(a.oldPath)} ${chalk.white("→")} ${chalk.gray(a.filePath)}`
|
|
1378
|
+
: "";
|
|
1379
|
+
|
|
1380
|
+
console.log(`${num} ${act} | ${label}: ${type}${idInfo}${renameInfo} (${displayPath})`);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
console.log(chalk.blue("\n--- Publishing Changes ---"));
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Execute actions
|
|
1388
|
+
if (filteredActionQueue.length > 0) {
|
|
1389
|
+
if (silent) {
|
|
1390
|
+
await performNetworkRequest(filteredActionQueue);
|
|
1391
|
+
} else {
|
|
1392
|
+
await withSpinner(
|
|
1393
|
+
"Working...",
|
|
1394
|
+
async () => {
|
|
1395
|
+
await performNetworkRequest(filteredActionQueue)
|
|
1396
|
+
},
|
|
1397
|
+
{ showCompletion: false }
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
actionQueue: filteredActionQueue,
|
|
1404
|
+
hasChanges: filteredActionQueue.length > 0
|
|
1405
|
+
};
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* CLI command wrapper for publish.
|
|
1410
|
+
*/
|
|
1411
|
+
export const publish = async () => {
|
|
1412
|
+
process.stdout.write("\x1Bc"); // clear console
|
|
1413
|
+
|
|
1414
|
+
try {
|
|
1415
|
+
await runPublish({ silent: false });
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
console.error(chalk.red.bold("Publish failed:"), err.message);
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
};
|