@magentrix-corp/magentrix-cli 1.1.4 → 1.2.0
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/README.md +156 -8
- package/actions/config.js +182 -0
- package/actions/create.js +30 -9
- package/actions/publish.js +434 -76
- package/actions/pull.js +361 -70
- package/actions/setup.js +2 -2
- package/actions/update.js +248 -0
- package/bin/magentrix.js +13 -1
- package/package.json +1 -1
- package/utils/assetPaths.js +24 -4
- package/utils/cacher.js +122 -53
- package/utils/cli/helpers/ensureInstanceUrl.js +3 -3
- package/utils/cli/writeRecords.js +34 -6
- package/utils/diagnostics/testPublishLogic.js +96 -0
- package/utils/downloadAssets.js +230 -19
- package/utils/logger.js +283 -0
- package/utils/magentrix/api/assets.js +65 -10
- package/utils/progress.js +383 -0
- package/utils/updateFileBase.js +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { walkFiles } from '../cacher.js';
|
|
2
|
+
import Config from '../config.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const config = new Config();
|
|
6
|
+
const EXPORT_ROOT = 'src';
|
|
7
|
+
|
|
8
|
+
// Load cache
|
|
9
|
+
const hits = await config.searchObject({}, { filename: "base.json", global: false });
|
|
10
|
+
const cachedResults = hits?.[0]?.value || {};
|
|
11
|
+
|
|
12
|
+
const cachedFiles = Object.values(cachedResults).map((c) => ({
|
|
13
|
+
...c,
|
|
14
|
+
tag: c.recordId,
|
|
15
|
+
filePath: c.filePath || c.lastKnownPath,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
console.log('Total cached files:', cachedFiles.length);
|
|
19
|
+
console.log('Cached assets (type=File):', cachedFiles.filter(cf => cf.type === 'File').length);
|
|
20
|
+
console.log('Cached folders (type=Folder):', cachedFiles.filter(cf => cf.type === 'Folder').length);
|
|
21
|
+
console.log('');
|
|
22
|
+
|
|
23
|
+
// Build Set like in publish
|
|
24
|
+
const cachedAssetPaths = new Set();
|
|
25
|
+
cachedFiles
|
|
26
|
+
.filter(cf => cf.type === 'File' || cf.type === 'Folder')
|
|
27
|
+
.forEach(cf => {
|
|
28
|
+
if (cf.lastKnownActualPath) {
|
|
29
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownActualPath)).toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
if (cf.filePath) {
|
|
32
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.filePath)).toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
if (cf.lastKnownPath) {
|
|
35
|
+
cachedAssetPaths.add(path.normalize(path.resolve(cf.lastKnownPath)).toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log('Cached asset paths in Set:', cachedAssetPaths.size);
|
|
40
|
+
console.log('');
|
|
41
|
+
|
|
42
|
+
// Get local assets
|
|
43
|
+
const assetPaths = await walkFiles(path.join(EXPORT_ROOT, 'Assets'));
|
|
44
|
+
console.log('Local asset files found:', assetPaths.length);
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
// Check first few
|
|
48
|
+
let matched = 0;
|
|
49
|
+
let notMatched = 0;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < Math.min(10, assetPaths.length); i++) {
|
|
52
|
+
const assetPath = assetPaths[i];
|
|
53
|
+
const normalizedAssetPath = path.normalize(path.resolve(assetPath)).toLowerCase();
|
|
54
|
+
const inCache = cachedAssetPaths.has(normalizedAssetPath);
|
|
55
|
+
|
|
56
|
+
if (inCache) {
|
|
57
|
+
matched++;
|
|
58
|
+
} else {
|
|
59
|
+
notMatched++;
|
|
60
|
+
console.log('NOT IN CACHE:', assetPath);
|
|
61
|
+
console.log(' Normalized:', normalizedAssetPath);
|
|
62
|
+
console.log('');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(`First 10 files: ${matched} matched, ${notMatched} not matched`);
|
|
67
|
+
console.log('');
|
|
68
|
+
|
|
69
|
+
// Check if the Set actually contains the sample path
|
|
70
|
+
const samplePath = assetPaths[0];
|
|
71
|
+
const sampleNormalized = path.normalize(path.resolve(samplePath)).toLowerCase();
|
|
72
|
+
console.log('Sample path:', samplePath);
|
|
73
|
+
console.log('Normalized:', sampleNormalized);
|
|
74
|
+
console.log('In Set:', cachedAssetPaths.has(sampleNormalized));
|
|
75
|
+
console.log('');
|
|
76
|
+
|
|
77
|
+
// Check what's actually in the Set for this file
|
|
78
|
+
const sampleInBase = Object.values(cachedResults).find(b =>
|
|
79
|
+
b.filePath === 'Assets/Acronis/Banners/1.png' ||
|
|
80
|
+
b.lastKnownActualPath === 'src/Assets/Acronis/Banners/1.png'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (sampleInBase) {
|
|
84
|
+
console.log('Found in base.json:');
|
|
85
|
+
console.log(' type:', sampleInBase.type);
|
|
86
|
+
console.log(' filePath:', sampleInBase.filePath);
|
|
87
|
+
console.log(' lastKnownActualPath:', sampleInBase.lastKnownActualPath);
|
|
88
|
+
console.log(' lastKnownPath:', sampleInBase.lastKnownPath);
|
|
89
|
+
|
|
90
|
+
// Check what gets added to the Set
|
|
91
|
+
if (sampleInBase.lastKnownActualPath) {
|
|
92
|
+
const normalized = path.normalize(path.resolve(sampleInBase.lastKnownActualPath)).toLowerCase();
|
|
93
|
+
console.log(' Normalized lastKnownActualPath:', normalized);
|
|
94
|
+
console.log(' Match:', normalized === sampleNormalized);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/utils/downloadAssets.js
CHANGED
|
@@ -6,6 +6,11 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
6
6
|
import fspath from 'path';
|
|
7
7
|
import { setFileTag } from "./filetag.js";
|
|
8
8
|
import { toLocalPath } from "./assetPaths.js";
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import Config from "./config.js";
|
|
11
|
+
import { sha256 } from "./hash.js";
|
|
12
|
+
|
|
13
|
+
const config = new Config();
|
|
9
14
|
|
|
10
15
|
export const walkAssets = async (instanceUrl, token, assetPath) => {
|
|
11
16
|
const assetResults = await listAssets(instanceUrl, token, assetPath);
|
|
@@ -35,46 +40,252 @@ export const walkAssets = async (instanceUrl, token, assetPath) => {
|
|
|
35
40
|
return walkedAssets;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export const downloadAssets = async (instanceUrl, token, path) => {
|
|
43
|
+
export const downloadAssets = async (instanceUrl, token, path, progressCallback = null, logger = null) => {
|
|
39
44
|
const allAssets = await walkAssets(instanceUrl, token, path);
|
|
40
45
|
|
|
46
|
+
// Count total files for progress tracking
|
|
47
|
+
let totalFiles = 0;
|
|
48
|
+
let downloadedFiles = 0;
|
|
49
|
+
|
|
50
|
+
const countFiles = (assets) => {
|
|
51
|
+
for (const asset of assets) {
|
|
52
|
+
if (asset.Type === 'File') {
|
|
53
|
+
totalFiles++;
|
|
54
|
+
}
|
|
55
|
+
if (asset.Type === 'Folder' && asset.Children) {
|
|
56
|
+
countFiles(asset.Children);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
countFiles(allAssets);
|
|
61
|
+
|
|
62
|
+
// Collect all base updates to write at the end (batch operation)
|
|
63
|
+
const baseUpdates = {};
|
|
64
|
+
|
|
41
65
|
const iterateDownload = async (assets) => {
|
|
66
|
+
// Handle empty assets array
|
|
67
|
+
if (!assets || assets.length === 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
const parentApiPath = assets?.[0]?.ParentApiPath; // Use API path for API calls
|
|
43
72
|
const parentLocalFolder = assets?.[0]?.ParentFolder; // Use local path for file system
|
|
44
73
|
const folders = assets.filter(asset => asset.Type === 'Folder');
|
|
45
74
|
const files = assets.filter(asset => asset.Type === 'File');
|
|
46
75
|
|
|
47
76
|
for (const folder of folders) {
|
|
48
|
-
|
|
77
|
+
const folderPath = fspath.join(EXPORT_ROOT, folder.Path);
|
|
78
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
79
|
+
|
|
80
|
+
// Collect base update for folder
|
|
81
|
+
if (fs.existsSync(folderPath)) {
|
|
82
|
+
const folderStats = fs.statSync(folderPath);
|
|
83
|
+
baseUpdates[folder.Path] = {
|
|
84
|
+
lastModified: folderStats.mtimeMs,
|
|
85
|
+
contentHash: '',
|
|
86
|
+
compressedContent: '',
|
|
87
|
+
recordId: folder.Path,
|
|
88
|
+
type: folder.Type,
|
|
89
|
+
filePath: folder.Path,
|
|
90
|
+
lastKnownActualPath: folderPath,
|
|
91
|
+
lastKnownPath: fspath.resolve(folder.Path)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
49
95
|
await iterateDownload(folder?.Children || []);
|
|
50
96
|
}
|
|
51
97
|
|
|
52
98
|
if (files.length > 0) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
outFile: fspath.join(EXPORT_ROOT, parentLocalFolder, 'assets.zip'), // Use local path for file system
|
|
59
|
-
});
|
|
99
|
+
// Batch files dynamically to avoid URL length limits
|
|
100
|
+
// Testing shows the API fails at ~2150 chars (likely 2048 limit on the server)
|
|
101
|
+
// We use 2000 as a safe limit with buffer
|
|
102
|
+
const MAX_URL_LENGTH = 2000;
|
|
103
|
+
const fileBatches = [];
|
|
60
104
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
105
|
+
// Calculate base URL length once (everything except the file names)
|
|
106
|
+
const baseUrl = new URL("/api/3.0/staticassets/download", instanceUrl);
|
|
107
|
+
baseUrl.searchParams.set("path", parentApiPath);
|
|
108
|
+
baseUrl.searchParams.set('download-format', 'zip');
|
|
109
|
+
// The names will be added as: &names=encoded_comma_separated_list
|
|
110
|
+
// Calculate the base without names parameter
|
|
111
|
+
const baseUrlStr = baseUrl.toString();
|
|
112
|
+
const baseLength = baseUrlStr.length + '&names='.length;
|
|
113
|
+
|
|
114
|
+
let currentBatch = [];
|
|
115
|
+
let currentNames = '';
|
|
116
|
+
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
// Calculate what the names parameter would be if we add this file
|
|
119
|
+
const testNames = currentNames
|
|
120
|
+
? `${currentNames},${file.Name}`
|
|
121
|
+
: file.Name;
|
|
122
|
+
|
|
123
|
+
// Calculate the URL length with this names string encoded
|
|
124
|
+
const encodedTestNames = encodeURIComponent(testNames);
|
|
125
|
+
const testLength = baseLength + encodedTestNames.length;
|
|
126
|
+
|
|
127
|
+
// Check if adding this file would exceed the URL limit
|
|
128
|
+
if (testLength > MAX_URL_LENGTH && currentBatch.length > 0) {
|
|
129
|
+
// Start a new batch - current batch is full
|
|
130
|
+
fileBatches.push([...currentBatch]);
|
|
131
|
+
currentBatch = [file];
|
|
132
|
+
currentNames = file.Name;
|
|
133
|
+
} else {
|
|
134
|
+
// Add to current batch
|
|
135
|
+
currentBatch.push(file);
|
|
136
|
+
currentNames = testNames;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Add the last batch if it has files
|
|
141
|
+
if (currentBatch.length > 0) {
|
|
142
|
+
fileBatches.push(currentBatch);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (let batchIndex = 0; batchIndex < fileBatches.length; batchIndex++) {
|
|
146
|
+
const batch = fileBatches[batchIndex];
|
|
147
|
+
|
|
148
|
+
// Skip empty batches
|
|
149
|
+
if (!batch || batch.length === 0) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const batchZipName = fileBatches.length > 1
|
|
154
|
+
? `assets-batch-${batchIndex + 1}.zip`
|
|
155
|
+
: 'assets.zip';
|
|
64
156
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
157
|
+
try {
|
|
158
|
+
// Validate that we have the required data
|
|
159
|
+
if (!parentApiPath) {
|
|
160
|
+
console.warn(`Warning: Skipping batch because parentApiPath is undefined. Files: ${batch.map(f => f.Name).join(', ')}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
71
163
|
|
|
72
|
-
|
|
164
|
+
if (!parentLocalFolder) {
|
|
165
|
+
console.warn(`Warning: Skipping batch because parentLocalFolder is undefined. Path: ${parentApiPath}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Debug logging for problematic paths
|
|
170
|
+
if (logger && parentApiPath.includes('@')) {
|
|
171
|
+
logger.info('Downloading batch with special characters', {
|
|
172
|
+
path: parentApiPath,
|
|
173
|
+
fileCount: batch.length,
|
|
174
|
+
firstFile: batch[0]?.Name
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const savedAs = await downloadAssetsZip({
|
|
179
|
+
baseUrl: instanceUrl,
|
|
180
|
+
token: token, // "Bearer" prefix added in code
|
|
181
|
+
path: parentApiPath, // Use API path for API call
|
|
182
|
+
names: batch.map(file => file.Name),
|
|
183
|
+
outFile: fspath.join(EXPORT_ROOT, parentLocalFolder, batchZipName), // Use local path for file system
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await extract(savedAs, {
|
|
187
|
+
dir: fspath.resolve(fspath.join(EXPORT_ROOT, parentLocalFolder)) // Use local path for extraction
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
fs.rmSync(savedAs);
|
|
191
|
+
|
|
192
|
+
// Collect base updates for all files in this batch
|
|
193
|
+
// We'll write them all at once at the end for performance
|
|
194
|
+
for (const file of batch) {
|
|
195
|
+
try {
|
|
196
|
+
const filePath = fspath.join(EXPORT_ROOT, file.Path);
|
|
197
|
+
if (fs.existsSync(filePath)) {
|
|
198
|
+
const fileStats = fs.statSync(filePath);
|
|
199
|
+
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
200
|
+
const contentHash = sha256(fileContent);
|
|
201
|
+
|
|
202
|
+
baseUpdates[file.Path] = {
|
|
203
|
+
lastModified: fileStats.mtimeMs,
|
|
204
|
+
contentHash,
|
|
205
|
+
compressedContent: '', // Assets don't store content
|
|
206
|
+
recordId: file.Path,
|
|
207
|
+
type: file.Type,
|
|
208
|
+
filePath: file.Path,
|
|
209
|
+
lastKnownActualPath: filePath,
|
|
210
|
+
lastKnownPath: fspath.resolve(file.Path)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// Log but don't fail the download
|
|
215
|
+
if (logger) {
|
|
216
|
+
logger.warning(`Failed to prepare base update for ${file.Path}`, { error: err.message });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Update progress
|
|
222
|
+
downloadedFiles += batch.length;
|
|
223
|
+
if (progressCallback) {
|
|
224
|
+
progressCallback(downloadedFiles, totalFiles, `Downloaded ${downloadedFiles}/${totalFiles} files`);
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Check if this is a 404 error (files don't exist on server)
|
|
228
|
+
const is404 = error.message && error.message.includes('404');
|
|
229
|
+
|
|
230
|
+
if (is404) {
|
|
231
|
+
// Files don't exist on server - log info but continue
|
|
232
|
+
// This is expected behavior for files that were deleted on the server
|
|
233
|
+
const fileNames = batch.map(f => f.Name).join(', ');
|
|
234
|
+
const infoMessage = `Skipped ${batch.length} missing files from ${parentApiPath || '(undefined path)'}`;
|
|
235
|
+
|
|
236
|
+
console.warn(chalk.gray(`\n ℹ️ ${infoMessage}`));
|
|
237
|
+
console.warn(chalk.gray(` These files don't exist on the server anymore.`));
|
|
238
|
+
|
|
239
|
+
// Log to file if logger is available (as INFO, not WARNING)
|
|
240
|
+
if (logger) {
|
|
241
|
+
logger.info(infoMessage, {
|
|
242
|
+
path: parentApiPath,
|
|
243
|
+
fileCount: batch.length,
|
|
244
|
+
firstFewFiles: fileNames.substring(0, 200) + (fileNames.length > 200 ? '...' : '')
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Still update progress as if we "downloaded" them (they don't exist)
|
|
249
|
+
downloadedFiles += batch.length;
|
|
250
|
+
if (progressCallback) {
|
|
251
|
+
progressCallback(downloadedFiles, totalFiles, `Skipped ${batch.length} missing files`);
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// Other errors should still fail
|
|
255
|
+
const fileNames = batch.map(f => f.Name).join(', ');
|
|
256
|
+
console.error(`\nFailed to download batch from ${parentApiPath || '(undefined path)'}`);
|
|
257
|
+
console.error(`Files: ${fileNames}`);
|
|
258
|
+
console.error(`Error: ${error.message}\n`);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
73
263
|
}
|
|
74
264
|
}
|
|
75
265
|
|
|
76
266
|
await iterateDownload(allAssets);
|
|
77
267
|
|
|
268
|
+
// Write all base updates in ONE operation at the end (super fast!)
|
|
269
|
+
if (Object.keys(baseUpdates).length > 0) {
|
|
270
|
+
if (logger) {
|
|
271
|
+
logger.info(`Writing ${Object.keys(baseUpdates).length} asset base updates to base.json`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Read current base.json once (pass null as key to get entire config)
|
|
275
|
+
const baseConfig = config.read(null, { filename: "base.json" }) || {};
|
|
276
|
+
|
|
277
|
+
// Merge all updates
|
|
278
|
+
Object.assign(baseConfig, baseUpdates);
|
|
279
|
+
|
|
280
|
+
// Write once
|
|
281
|
+
const baseJsonPath = fspath.join(fspath.dirname(config.projectConfigPath), "base.json");
|
|
282
|
+
fs.writeFileSync(baseJsonPath, JSON.stringify(baseConfig, null, 2), { mode: 0o600 });
|
|
283
|
+
|
|
284
|
+
if (logger) {
|
|
285
|
+
logger.info(`Successfully wrote all asset base updates`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
78
289
|
return {
|
|
79
290
|
tree: allAssets
|
|
80
291
|
};
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import Config from './config.js';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
|
|
7
|
+
const config = new Config();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Logger utility for tracking errors, warnings, and operations during pull/publish.
|
|
11
|
+
* Logs are stored in .magentrix/logs/ directory.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class Logger {
|
|
15
|
+
constructor(operationName = 'operation') {
|
|
16
|
+
this.operationName = operationName;
|
|
17
|
+
this.logDir = '.magentrix/logs';
|
|
18
|
+
this.timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
|
|
19
|
+
this.logFile = path.join(this.logDir, `${operationName}-${this.timestamp}.log`);
|
|
20
|
+
this.errors = [];
|
|
21
|
+
this.warnings = [];
|
|
22
|
+
this.infos = [];
|
|
23
|
+
this.loggingEnabled = null; // Will be determined on first write
|
|
24
|
+
|
|
25
|
+
// Check if logging is enabled in config
|
|
26
|
+
this.checkLoggingPreference();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check user's logging preference from config
|
|
31
|
+
*/
|
|
32
|
+
checkLoggingPreference() {
|
|
33
|
+
const preference = config.read('saveLogs', { global: true });
|
|
34
|
+
|
|
35
|
+
if (preference !== undefined && preference !== null) {
|
|
36
|
+
this.loggingEnabled = preference === true || preference === 'true';
|
|
37
|
+
}
|
|
38
|
+
// If undefined, we'll prompt on first write
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Prompt user about logging preference (only once)
|
|
43
|
+
*/
|
|
44
|
+
async promptLoggingPreference() {
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(chalk.cyan('📋 Log File Settings'));
|
|
47
|
+
console.log(chalk.gray('Magentrix CLI can save detailed operation logs for debugging.'));
|
|
48
|
+
console.log('');
|
|
49
|
+
|
|
50
|
+
const answer = await inquirer.prompt([
|
|
51
|
+
{
|
|
52
|
+
type: 'confirm',
|
|
53
|
+
name: 'saveLogs',
|
|
54
|
+
message: 'Would you like to save operation logs to files?',
|
|
55
|
+
default: true
|
|
56
|
+
}
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
this.loggingEnabled = answer.saveLogs;
|
|
60
|
+
config.save('saveLogs', answer.saveLogs, { global: true });
|
|
61
|
+
|
|
62
|
+
if (answer.saveLogs) {
|
|
63
|
+
console.log(chalk.green('✓ Logs will be saved to .magentrix/logs/'));
|
|
64
|
+
} else {
|
|
65
|
+
console.log(chalk.gray('Logs disabled. You can enable them anytime with: magentrix config set logs true'));
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Initialize log file if logging is enabled
|
|
72
|
+
*/
|
|
73
|
+
async initLogFile() {
|
|
74
|
+
if (this.loggingEnabled === null) {
|
|
75
|
+
// First time - prompt user
|
|
76
|
+
await this.promptLoggingPreference();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.loggingEnabled) {
|
|
80
|
+
// Ensure log directory exists
|
|
81
|
+
if (!fs.existsSync(this.logDir)) {
|
|
82
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Initialize log file
|
|
86
|
+
this.writeToFile(`=== ${this.operationName.toUpperCase()} LOG ===`);
|
|
87
|
+
this.writeToFile(`Started: ${new Date().toISOString()}`);
|
|
88
|
+
this.writeToFile(`Working Directory: ${process.cwd()}`);
|
|
89
|
+
this.writeToFile('');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write a line to the log file (internal method)
|
|
95
|
+
*/
|
|
96
|
+
writeToFile(message) {
|
|
97
|
+
if (this.loggingEnabled) {
|
|
98
|
+
fs.appendFileSync(this.logFile, message + '\n');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Log an info message
|
|
104
|
+
*/
|
|
105
|
+
info(message, details = null) {
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
const entry = { timestamp, level: 'INFO', message, details };
|
|
108
|
+
this.infos.push(entry);
|
|
109
|
+
|
|
110
|
+
this.writeToFile(`[${timestamp}] INFO: ${message}`);
|
|
111
|
+
if (details) {
|
|
112
|
+
this.writeToFile(` Details: ${typeof details === 'object' ? JSON.stringify(details, null, 2) : details}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Log a warning message
|
|
118
|
+
*/
|
|
119
|
+
warning(message, details = null) {
|
|
120
|
+
const timestamp = new Date().toISOString();
|
|
121
|
+
const entry = { timestamp, level: 'WARNING', message, details };
|
|
122
|
+
this.warnings.push(entry);
|
|
123
|
+
|
|
124
|
+
this.writeToFile(`[${timestamp}] WARNING: ${message}`);
|
|
125
|
+
if (details) {
|
|
126
|
+
this.writeToFile(` Details: ${typeof details === 'object' ? JSON.stringify(details, null, 2) : details}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Log an error message
|
|
132
|
+
*/
|
|
133
|
+
error(message, error = null, details = null) {
|
|
134
|
+
const timestamp = new Date().toISOString();
|
|
135
|
+
const entry = {
|
|
136
|
+
timestamp,
|
|
137
|
+
level: 'ERROR',
|
|
138
|
+
message,
|
|
139
|
+
error: error ? error.toString() : null,
|
|
140
|
+
stack: error?.stack || null,
|
|
141
|
+
details
|
|
142
|
+
};
|
|
143
|
+
this.errors.push(entry);
|
|
144
|
+
|
|
145
|
+
this.writeToFile(`[${timestamp}] ERROR: ${message}`);
|
|
146
|
+
if (error) {
|
|
147
|
+
this.writeToFile(` Error: ${error.toString()}`);
|
|
148
|
+
if (error.stack) {
|
|
149
|
+
this.writeToFile(` Stack: ${error.stack}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (details) {
|
|
153
|
+
this.writeToFile(` Details: ${typeof details === 'object' ? JSON.stringify(details, null, 2) : details}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get a summary of the log
|
|
159
|
+
*/
|
|
160
|
+
getSummary() {
|
|
161
|
+
return {
|
|
162
|
+
errors: this.errors.length,
|
|
163
|
+
warnings: this.warnings.length,
|
|
164
|
+
infos: this.infos.length,
|
|
165
|
+
logFile: this.logFile
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get the most recent errors (for preview)
|
|
171
|
+
*/
|
|
172
|
+
getRecentErrors(count = 3) {
|
|
173
|
+
return this.errors.slice(-count);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get the most recent warnings (for preview)
|
|
178
|
+
*/
|
|
179
|
+
getRecentWarnings(count = 3) {
|
|
180
|
+
return this.warnings.slice(-count);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Display a summary to the console
|
|
185
|
+
*/
|
|
186
|
+
displaySummary() {
|
|
187
|
+
const summary = this.getSummary();
|
|
188
|
+
|
|
189
|
+
if (summary.errors > 0 || summary.warnings > 0) {
|
|
190
|
+
console.log('');
|
|
191
|
+
console.log(chalk.bold('Issues Summary:'));
|
|
192
|
+
|
|
193
|
+
if (summary.errors > 0) {
|
|
194
|
+
console.log(chalk.red(` ✗ ${summary.errors} error(s)`));
|
|
195
|
+
|
|
196
|
+
// Show preview of recent errors
|
|
197
|
+
const recentErrors = this.getRecentErrors(2);
|
|
198
|
+
recentErrors.forEach((err, idx) => {
|
|
199
|
+
console.log(chalk.red(` ${idx + 1}. ${err.message}`));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (summary.errors > 2) {
|
|
203
|
+
console.log(chalk.gray(` ... and ${summary.errors - 2} more`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (summary.warnings > 0) {
|
|
208
|
+
console.log(chalk.yellow(` ⚠ ${summary.warnings} warning(s)`));
|
|
209
|
+
|
|
210
|
+
// Show preview of recent warnings
|
|
211
|
+
const recentWarnings = this.getRecentWarnings(2);
|
|
212
|
+
recentWarnings.forEach((warn, idx) => {
|
|
213
|
+
console.log(chalk.yellow(` ${idx + 1}. ${warn.message}`));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (summary.warnings > 2) {
|
|
217
|
+
console.log(chalk.gray(` ... and ${summary.warnings - 2} more`));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Only show log file path if logging is enabled
|
|
222
|
+
if (this.loggingEnabled) {
|
|
223
|
+
console.log('');
|
|
224
|
+
console.log(chalk.cyan(`📄 Full log: ${chalk.white(summary.logFile)}`));
|
|
225
|
+
console.log(chalk.gray(` View with: cat ${summary.logFile}`));
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Finalize the log file
|
|
233
|
+
*/
|
|
234
|
+
close() {
|
|
235
|
+
this.writeToFile('');
|
|
236
|
+
this.writeToFile(`Completed: ${new Date().toISOString()}`);
|
|
237
|
+
this.writeToFile(`Summary: ${this.errors.length} errors, ${this.warnings.length} warnings, ${this.infos.length} info messages`);
|
|
238
|
+
this.writeToFile('=== END LOG ===');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Clean up old log files (keep only last N logs)
|
|
243
|
+
*/
|
|
244
|
+
static cleanupOldLogs(keepCount = 10) {
|
|
245
|
+
const logDir = '.magentrix/logs';
|
|
246
|
+
|
|
247
|
+
if (!fs.existsSync(logDir)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const files = fs.readdirSync(logDir)
|
|
253
|
+
.filter(f => f.endsWith('.log'))
|
|
254
|
+
.map(f => ({
|
|
255
|
+
name: f,
|
|
256
|
+
path: path.join(logDir, f),
|
|
257
|
+
time: fs.statSync(path.join(logDir, f)).mtime.getTime()
|
|
258
|
+
}))
|
|
259
|
+
.sort((a, b) => b.time - a.time); // Sort by newest first
|
|
260
|
+
|
|
261
|
+
// Delete files beyond keepCount
|
|
262
|
+
if (files.length > keepCount) {
|
|
263
|
+
const toDelete = files.slice(keepCount);
|
|
264
|
+
toDelete.forEach(file => {
|
|
265
|
+
try {
|
|
266
|
+
fs.unlinkSync(file.path);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
// Ignore errors when deleting old logs
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// Ignore errors in cleanup
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Create a logger instance for an operation
|
|
280
|
+
*/
|
|
281
|
+
export function createLogger(operationName) {
|
|
282
|
+
return new Logger(operationName);
|
|
283
|
+
}
|