@magentrix-corp/magentrix-cli 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +471 -0
  3. package/actions/autopublish.js +283 -0
  4. package/actions/autopublish.old.js +293 -0
  5. package/actions/autopublish.v2.js +447 -0
  6. package/actions/create.js +329 -0
  7. package/actions/help.js +165 -0
  8. package/actions/main.js +81 -0
  9. package/actions/publish.js +567 -0
  10. package/actions/pull.js +139 -0
  11. package/actions/setup.js +61 -0
  12. package/actions/status.js +17 -0
  13. package/bin/magentrix.js +159 -0
  14. package/package.json +61 -0
  15. package/utils/cacher.js +112 -0
  16. package/utils/cli/checkInstanceUrl.js +29 -0
  17. package/utils/cli/helpers/compare.js +281 -0
  18. package/utils/cli/helpers/ensureApiKey.js +57 -0
  19. package/utils/cli/helpers/ensureCredentials.js +60 -0
  20. package/utils/cli/helpers/ensureInstanceUrl.js +63 -0
  21. package/utils/cli/writeRecords.js +223 -0
  22. package/utils/compare.js +135 -0
  23. package/utils/compress.js +18 -0
  24. package/utils/config.js +451 -0
  25. package/utils/diff.js +49 -0
  26. package/utils/downloadAssets.js +75 -0
  27. package/utils/filetag.js +115 -0
  28. package/utils/hash.js +14 -0
  29. package/utils/magentrix/api/assets.js +145 -0
  30. package/utils/magentrix/api/auth.js +56 -0
  31. package/utils/magentrix/api/createEntity.js +61 -0
  32. package/utils/magentrix/api/deleteEntity.js +55 -0
  33. package/utils/magentrix/api/meqlQuery.js +31 -0
  34. package/utils/magentrix/api/retrieveEntity.js +32 -0
  35. package/utils/magentrix/api/updateEntity.js +66 -0
  36. package/utils/magentrix/fetch.js +154 -0
  37. package/utils/merge.js +22 -0
  38. package/utils/preferences.js +40 -0
  39. package/utils/spinner.js +43 -0
  40. package/utils/template.js +52 -0
  41. package/utils/updateFileBase.js +103 -0
  42. package/vars/config.js +1 -0
  43. package/vars/global.js +33 -0
@@ -0,0 +1,281 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk'; // For pretty colors, install with `npm install chalk`
3
+ import { compareLocalAndRemote } from '../../compare.js';
4
+ import inquirer from 'inquirer';
5
+ import { mapRecordToFile } from '../writeRecords.js';
6
+ import { withSpinner } from '../../spinner.js';
7
+ import { meqlQuery } from '../../magentrix/api/meqlQuery.js';
8
+ import readline from 'readline';
9
+
10
+ /**
11
+ * Logs a formatted, user-friendly status message for a single file, given its sync comparison result.
12
+ *
13
+ * @param {string} relativePath - File path relative to root directory.
14
+ * @param {Object} statusResult - Result object from compareLocalAndRemote.
15
+ * @returns {boolean} True if an issue was found (not 'in_sync' or 'missing'), otherwise false.
16
+ */
17
+ export function logFileStatus(relativePath, statusResult) {
18
+ const fileDisplay = chalk.bold.blue(relativePath);
19
+
20
+ switch (statusResult.status) {
21
+ case 'in_sync':
22
+ // Uncomment to show successes:
23
+ // console.log(chalk.green(`✓ ${fileDisplay} is up to date.`));
24
+ return false;
25
+ case 'behind':
26
+ console.log(
27
+ chalk.yellow(`⚠️ ${fileDisplay} is OUTDATED:`) +
28
+ `\n Local version is older than the server version, but contents are identical.\n` +
29
+ ` Consider pulling the latest metadata from the server.`
30
+ );
31
+ return true;
32
+ case 'conflict':
33
+ console.log(
34
+ chalk.red(`🛑 ${fileDisplay} has a CONFLICT:`) +
35
+ `\n Local file is behind the remote, AND both were edited differently.\n` +
36
+ ` Resolve this conflict before pushing or pulling.`
37
+ );
38
+ return true;
39
+ case 'ahead':
40
+ console.log(
41
+ chalk.yellow(`⚠️ ${fileDisplay} is AHEAD:`) +
42
+ `\n Local file was modified after the remote version, and contents differ.\n` +
43
+ ` You may want to push your changes to the server.`
44
+ );
45
+ return true;
46
+ case 'ahead_identical':
47
+ console.log(
48
+ chalk.yellow(`⚠️ ${fileDisplay} is AHEAD (identical):`) +
49
+ `\n Local file has a newer timestamp, but contents match remote.\n` +
50
+ ` This could be a system clock drift.`
51
+ );
52
+ return true;
53
+ case 'content_differs':
54
+ console.log(
55
+ chalk.red(`🛑 ${fileDisplay} CONTENT MISMATCH:`) +
56
+ `\n File timestamps match, but contents differ!\n` +
57
+ ` This is unusual—please investigate.`
58
+ );
59
+ return true;
60
+ case 'missing':
61
+ // Not an error, file will be downloaded.
62
+ // Uncomment if you want to notify about downloads:
63
+ console.log(
64
+ chalk.yellow(`⚠️ ${fileDisplay} is MISSING:`) +
65
+ `\n Local file has been removed.`
66
+ );
67
+
68
+ // console.log(chalk.cyan(`⬇️ ${fileDisplay} will be downloaded from the server.`));
69
+ return true;
70
+ default:
71
+ console.log(
72
+ chalk.magenta(`❓ ${fileDisplay} has an unknown status: ${statusResult.status}`)
73
+ );
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Compares all local files in a specified root directory against their remote versions,
80
+ * then logs user-friendly, formatted status messages for any files that are not in sync.
81
+ *
82
+ * - Uses logFileStatus to handle message formatting for each file.
83
+ * - Only logs files that require attention; in-sync files are silent by default.
84
+ * - If no issues are found, logs a celebratory success message.
85
+ * - Intended for CLI status or sync-check commands.
86
+ *
87
+ * @param {string} rootDir
88
+ * The local root directory where your files are stored (e.g., './export').
89
+ * @param {Array<Object>} fileRecords
90
+ * Array of remote file descriptors to compare. Each object should include:
91
+ * - {string} relativePath: File path relative to rootDir (e.g. 'Controllers/Foo.ctrl')
92
+ * - {string} content: Latest file content from the server
93
+ * - {string} ModifiedOn: Last modified date/time on server (ISO string)
94
+ *
95
+ * @returns {void}
96
+ *
97
+ * @example
98
+ * const filesFromServer = [
99
+ * {
100
+ * relativePath: "Controllers/AccountController.ctrl",
101
+ * content: "...",
102
+ * ModifiedOn: "2025-07-10T18:00:00.000Z"
103
+ * },
104
+ * // ...
105
+ * ];
106
+ * compareAllFilesAndLogStatus('./export', filesFromServer);
107
+ */
108
+ export function compareAllFilesAndLogStatus(rootDir, fileRecords) {
109
+ let numIssues = 0;
110
+
111
+ console.log();
112
+
113
+ for (const record of fileRecords) {
114
+ const localFilePath = path.join(rootDir, record.relativePath);
115
+
116
+ const result = compareLocalAndRemote(localFilePath, {
117
+ content: record.Content,
118
+ ...record
119
+ });
120
+
121
+ // Use the single-file logger and aggregate if any issues found
122
+ const hadIssue = logFileStatus(record.relativePath, result);
123
+ if (hadIssue) numIssues++;
124
+ }
125
+
126
+ if (numIssues === 0) {
127
+ console.log(chalk.green.bold('\n🎉 All files are up to date and in sync!\n'));
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Checks local vs remote Magentrix file status and warns on conflicts/out-of-sync.
133
+ * If issues are found, blocks execution unless `forceContinue` is true.
134
+ * If not forced, user can press any key to continue, or ESC to abort.
135
+ *
136
+ * @param {string} rootDir
137
+ * @param {string} instanceUrl
138
+ * @param {string} token
139
+ * @param {boolean} [forceContinue=false]
140
+ */
141
+ export async function showCurrentConflicts(rootDir, instanceUrl, token, forceContinue = false) {
142
+ const queries = [
143
+ {
144
+ name: "ActiveClass",
145
+ query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
146
+ contentField: "Body",
147
+ },
148
+ {
149
+ name: "ActivePage",
150
+ query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
151
+ contentField: "Content",
152
+ },
153
+ ];
154
+
155
+ console.log(chalk.cyan.bold('🔍 Checking local files vs remote Magentrix...'));
156
+ console.log(chalk.gray('------------------------------------------------'));
157
+
158
+ const [activeClassResult, activePageResult] = await withSpinner(
159
+ chalk.gray('Retrieving files from server...'),
160
+ async () => Promise.all(
161
+ queries.map(q => meqlQuery(instanceUrl, token, q.query))
162
+ )
163
+ );
164
+
165
+ const activeClassRecords = (activeClassResult.Records || []).map(record => {
166
+ record.Content = record.Body;
167
+ delete record.Body;
168
+ return record;
169
+ });
170
+ const activePageRecords = (activePageResult.Records || []);
171
+ const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
172
+
173
+ let warningCount = 0;
174
+ console.log();
175
+
176
+ for (const record of allRecords) {
177
+ if (record?.error) {
178
+ continue;
179
+ }
180
+
181
+ const status = compareLocalAndRemote(
182
+ path.join(rootDir, record.relativePath),
183
+ { ...record, content: record.Content }
184
+ );
185
+
186
+ const hasIssue = logFileStatus(record.relativePath, status);
187
+ if (hasIssue) warningCount++;
188
+ }
189
+
190
+ if (warningCount > 0) {
191
+ console.log();
192
+ console.log(
193
+ chalk.yellow.bold(`⚠️ Conflict${warningCount > 1 ? 's' : ''} detected!`) +
194
+ chalk.yellow('\n These are just warnings, but you should resolve them before continuing.')
195
+ );
196
+ console.log(
197
+ chalk.yellow('\n👉 To sync and resolve conflicts, run: ') +
198
+ chalk.cyan.bold('magentrix pull')
199
+ );
200
+ console.log(chalk.gray('------------------------------------------------'));
201
+
202
+ if (!forceContinue) {
203
+ await new Promise((resolve) => {
204
+ process.stdout.write(
205
+ chalk.yellow(
206
+ '\nPress any key to continue, or press ESC to abort... '
207
+ )
208
+ );
209
+
210
+ // Set raw mode so we capture a single key, including ESC
211
+ process.stdin.setRawMode(true);
212
+ process.stdin.resume();
213
+ process.stdin.once('data', (data) => {
214
+ process.stdin.setRawMode(false);
215
+ process.stdin.pause();
216
+ // ESC key is 27 in Buffer
217
+ if (data.length === 1 && data[0] === 27) {
218
+ console.log(chalk.red.bold('\nAborted due to unresolved conflicts.\n'));
219
+ process.exit(1);
220
+ } else {
221
+ // Continue
222
+ process.stdout.write('\n');
223
+ resolve();
224
+ }
225
+ });
226
+ });
227
+ }
228
+ } else {
229
+ console.log();
230
+ console.log(chalk.green.bold('🎉 All local files are in sync with the server!'));
231
+ console.log(chalk.gray('------------------------------------------------'));
232
+ }
233
+ }
234
+
235
+
236
+
237
+ /**
238
+ * Presents all files with conflicts and prompts the user for a global resolution action.
239
+ *
240
+ * @param {Array<{relativePath: string, status: string}>} fileIssues
241
+ * @returns {Promise<'skip'|'overwrite'|'diff'|'accept_ours'|'accept_theirs'|'manual'>}
242
+ */
243
+ export async function promptConflictResolution(fileIssues) {
244
+ if (!fileIssues.length) return 'skip';
245
+
246
+ // Clear for better UX
247
+ console.clear();
248
+ console.log(
249
+ chalk.bold.yellow(
250
+ `\n${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
251
+ )
252
+ );
253
+
254
+ fileIssues.forEach((file, i) => {
255
+ console.log(
256
+ chalk.cyan(`${i + 1}.`) +
257
+ ' ' +
258
+ chalk.bold(file.relativePath) +
259
+ chalk.gray(` [${file.status}]`)
260
+ );
261
+ });
262
+
263
+ console.log();
264
+
265
+ // Present actions menu
266
+ const { action } = await inquirer.prompt([
267
+ {
268
+ type: 'list',
269
+ name: 'action',
270
+ message: chalk.green('Choose how to resolve these conflicts:'),
271
+ choices: [
272
+ { name: 'Replace local files with server versions (discard my changes)', value: 'overwrite' },
273
+ { name: 'Automatically merge changes (may require manual edits if conflicts remain)', value: 'merge' },
274
+ { name: 'Review each conflict and choose (see diffs)', value: 'diff' },
275
+ { name: 'Do nothing for now (skip conflicted files)', value: 'skip' }
276
+ ]
277
+ },
278
+ ]);
279
+
280
+ return action;
281
+ }
@@ -0,0 +1,57 @@
1
+ import prompts from "prompts";
2
+ import Config from "../../config.js";
3
+ import { HASHED_CWD } from "../../../vars/global.js";
4
+
5
+ const config = new Config();
6
+
7
+ /**
8
+ * Ensures a valid Magentrix API key is available in the global config.
9
+ * - If a key is missing, invalid, or forcePrompt is true, prompts the user to enter one.
10
+ * - Validates that the key is at least 12 characters long and contains no spaces.
11
+ * - Exits the process if the user fails to provide a valid key.
12
+ *
13
+ * @async
14
+ * @param {boolean} [forcePrompt=false] - If true, always prompt for API key, even if one exists.
15
+ * @returns {Promise<string>} The valid API key, either from config or user input.
16
+ */
17
+ export const ensureApiKey = async (forcePrompt = false) => {
18
+ let apiKey = config.read('apiKey', { global: true, pathHash: HASHED_CWD });
19
+
20
+ // Helper: checks for min length and no spaces
21
+ const isValid = (key) =>
22
+ typeof key === "string" &&
23
+ key.trim().length >= 12 &&
24
+ !/\s/.test(key);
25
+
26
+ if (forcePrompt || !isValid(apiKey)) {
27
+ console.log('\n🚀 Magentrix CLI Setup:');
28
+
29
+ while (true) {
30
+ const response = await prompts({
31
+ type: 'password',
32
+ name: 'apiKey',
33
+ message: 'Enter your Magentrix API key (no spaces):',
34
+ validate: value =>
35
+ !value || value.trim().length < 12
36
+ ? "API key must be at least 12 characters."
37
+ : /\s/.test(value)
38
+ ? "API key cannot contain any spaces."
39
+ : true,
40
+ });
41
+
42
+ if (!response.apiKey) {
43
+ console.error('❌ API key is required. Exiting.');
44
+ process.exit(1);
45
+ }
46
+
47
+ const trimmed = response.apiKey.trim();
48
+
49
+ if (isValid(trimmed)) {
50
+ return trimmed;
51
+ }
52
+ // Invalid: will re-prompt.
53
+ }
54
+ }
55
+
56
+ return apiKey;
57
+ };
@@ -0,0 +1,60 @@
1
+ import Config from "../../config.js";
2
+ import { setup } from "../../../actions/setup.js";
3
+ import { HASHED_CWD } from "../../../vars/global.js";
4
+ import { tryAuthenticate } from "../../magentrix/api/auth.js";
5
+
6
+ /**
7
+ * Returns true if the token is present and not expired (60 seconds buffer).
8
+ * @param {{ value?: string, validUntil?: string } | undefined} tokenObj
9
+ * @returns {boolean}
10
+ */
11
+ export function isTokenValid(tokenObj) {
12
+ if (!tokenObj || !tokenObj.value || !tokenObj.validUntil) return false;
13
+ const now = Date.now();
14
+ const expiresAt = Date.parse(tokenObj.validUntil);
15
+ if (isNaN(expiresAt)) return false;
16
+ const SECONDS_BEFORE_EXPIRY = 60 * 1000;
17
+ return expiresAt - now > SECONDS_BEFORE_EXPIRY;
18
+ }
19
+
20
+ /**
21
+ * Ensures Magentrix credentials are present and valid, refreshing the token if needed.
22
+ * Prompts user only when needed.
23
+ *
24
+ * @returns {Promise<{ apiKey: string, instanceUrl: string, token: { value: string, validUntil: string } }>}
25
+ */
26
+ export async function ensureValidCredentials() {
27
+ const config = new Config();
28
+
29
+ // Load from config
30
+ const apiKey = config.read('apiKey', { global: true, pathHash: HASHED_CWD });
31
+ const instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD });
32
+ const token = config.read('token', { global: true, pathHash: HASHED_CWD });
33
+
34
+ // If missing API key or URL, prompt/setup immediately
35
+ if (!apiKey || !instanceUrl) {
36
+ return setup();
37
+ }
38
+
39
+ // If token is present and valid, return immediately
40
+ if (isTokenValid(token)) {
41
+ return { apiKey, instanceUrl, token };
42
+ }
43
+
44
+ // If we have API key & URL but no valid token, try to refresh
45
+ try {
46
+ const result = await tryAuthenticate(apiKey, instanceUrl);
47
+
48
+ if (!result || !result.token || !result.validUntil) {
49
+ throw new Error("Invalid token refresh response");
50
+ }
51
+
52
+ // Save new token and return
53
+ const newToken = { value: result.token, validUntil: result.validUntil };
54
+ config.save('token', newToken, { global: true, pathHash: HASHED_CWD });
55
+ return { apiKey, instanceUrl, token: newToken };
56
+ } catch (err) {
57
+ // Failed to refresh, fall back to prompting the user
58
+ return setup();
59
+ }
60
+ }
@@ -0,0 +1,63 @@
1
+ import prompts from "prompts";
2
+ import Config from "../../config.js";
3
+ import { checkInstanceUrl } from "../checkInstanceUrl.js";
4
+ import { withSpinner } from "../../spinner.js";
5
+ import { HASHED_CWD } from "../../../vars/global.js";
6
+
7
+ const config = new Config();
8
+ const urlRegex = /^https:\/\/[a-zA-Z0-9-]+\.magentrix(cloud)?\.com$/;
9
+
10
+ /**
11
+ * Ensures a valid Magentrix instance URL is available in the global config.
12
+ * Prompts until user provides a valid, reachable URL or cancels.
13
+ *
14
+ * @async
15
+ * @param {boolean} [forcePrompt=false] - If true, always prompt for instance URL, even if one exists.
16
+ * @returns {Promise<string>} The valid instance URL, either from config or user input.
17
+ */
18
+ export const ensureInstanceUrl = async (forcePrompt = false) => {
19
+ let instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD });
20
+
21
+ if (
22
+ forcePrompt ||
23
+ !instanceUrl ||
24
+ typeof instanceUrl !== 'string' ||
25
+ !urlRegex.test(instanceUrl.trim())
26
+ ) {
27
+ console.log('\n🚀 Magentrix CLI Setup: Instance URL');
28
+
29
+ while (true) {
30
+ // Prompt: Only validate format, NOT reachability
31
+ const { instanceUrl: inputUrl } = await prompts({
32
+ type: 'text',
33
+ name: 'instanceUrl',
34
+ message: 'Enter your Magentrix instance URL (e.g., https://example.magentrixcloud.com):',
35
+ validate: value =>
36
+ urlRegex.test(value.trim())
37
+ ? true
38
+ : 'Instance URL must be in the form: https://subdomain.magentrixcloud.com (NO http, NO trailing /, NO extra path)',
39
+ });
40
+
41
+ if (!inputUrl) {
42
+ console.error('❌ Instance URL is required. Exiting.');
43
+ process.exit(1);
44
+ }
45
+
46
+ const trimmed = inputUrl.trim();
47
+
48
+ // Now check reachability WITH a spinner
49
+ try {
50
+ await withSpinner('Checking instance URL...', () =>
51
+ checkInstanceUrl(trimmed)
52
+ );
53
+
54
+ return trimmed;
55
+ } catch (error) {
56
+ // Print error AFTER spinner, then re-prompt
57
+ console.error('❌ Instance URL not reachable. Try again.');
58
+ }
59
+ }
60
+ }
61
+
62
+ return instanceUrl.trim();
63
+ };
@@ -0,0 +1,223 @@
1
+ import { EXPORT_ROOT, TYPE_DIR_MAP } from "../../vars/global.js";
2
+ import fs from 'fs';
3
+ import path from "path";
4
+ import { compareLocalAndRemote } from "../compare.js";
5
+ import { mergeFiles } from "../merge.js";
6
+ import Config from "../config.js";
7
+ import chalk from "chalk";
8
+ import { diffLines } from 'diff';
9
+ import readlineSync from "readline-sync";
10
+ import { updateBase } from "../updateFileBase.js";
11
+ import { sha256 } from "../hash.js";
12
+ import { openDiffInVSCode } from "../diff.js";
13
+ import { tmpdir } from "os";
14
+ import { v4 as uuidv4 } from 'uuid';
15
+ import { findFileByTag, getFileTag, setFileTag } from "../filetag.js";
16
+ import { decompressString } from "../compress.js";
17
+
18
+ const config = new Config();
19
+
20
+ /**
21
+ * Maps an ActivePage record to its local file representation.
22
+ * @param {Object} record - The ActivePage record from the API.
23
+ * @returns {Object} The enriched record with 'relativePath' property.
24
+ */
25
+ export const mapRecordToFile = (record) => {
26
+ const { Type, Name, Id } = record;
27
+ const mapping = TYPE_DIR_MAP[Type];
28
+
29
+ if (!mapping) {
30
+ const errMsg = `Unrecognized type "${Type}" for record "${Name || 'UNKNOWN'}". This can be resolved in the Magentrix IDE and is likely due to a misconfiguration.`;
31
+ return { ...record, error: errMsg };
32
+ }
33
+
34
+ const safeName = Name.replace(/[<>:"/\\|?*]+/g, "_");
35
+ const filename = `${safeName}.${mapping.extension}`;
36
+
37
+ const foundPathByRecordId = findFileByTag(Id) || "";
38
+ const relativeFoundPath = foundPathByRecordId
39
+ ? path.join(mapping.directory, foundPathByRecordId.split(mapping.directory)[1] || "")
40
+ : "";
41
+
42
+ const filePath = foundPathByRecordId ? relativeFoundPath : path.join(mapping.directory, filename);
43
+
44
+ return {
45
+ ...record,
46
+ relativePath: filePath
47
+ };
48
+ };
49
+
50
+ /**
51
+ * Writes records to disk, handling conflicts per the chosen resolution method.
52
+ * @param {Array} records - List of entity records (ActiveClass or ActivePage)
53
+ * @param {string} resolutionMethod - One of: "overwrite", "merge", "diff", "skip"
54
+ */
55
+ export const writeRecords = async (records, resolutionMethod) => {
56
+ for (const record of records) {
57
+ const mapping = TYPE_DIR_MAP[record.Type];
58
+ if (!mapping) {
59
+ console.warn(chalk.gray(`⚠️ Skipping unknown type: ${record.Type} (record: ${record.Name})`));
60
+ continue;
61
+ }
62
+
63
+ // Build output path and filename
64
+ const outputDir = path.join(EXPORT_ROOT, mapping.directory);
65
+ fs.mkdirSync(outputDir, { recursive: true }); // Ensure dir exists
66
+
67
+ const safeName = record.Name.replace(/[<>:"/\\|?*]+/g, "_");
68
+ const filename = `${safeName}.${mapping.extension}`;
69
+ const filePath = path.join(outputDir, filename);
70
+
71
+ const content = record.Content;
72
+ if (!content) {
73
+ console.warn(`⚠️ No content found for ${record.Name} (${record.Type}), skipping file.`);
74
+ continue;
75
+ }
76
+
77
+ // Compare local and remote to determine sync status/conflict
78
+ const comparison = compareLocalAndRemote(filePath, { content, ...record });
79
+
80
+ // --- Conflict Handling ---
81
+ let finalContent = null; // Will hold the string to write if we should write the file
82
+
83
+ if (['missing', 'in_sync'].includes(comparison.status)) {
84
+ // No conflict, just write server version (create if missing)
85
+ finalContent = content;
86
+ } else {
87
+ // Attempt to read local file (may not exist)
88
+ let local = "";
89
+
90
+ const base = config.read(record.Id, { global: false, filename: 'base.json' });
91
+ const baseContent = decompressString(base?.compressedContent) || '';
92
+
93
+ // const base = config.read(filePath);
94
+ try { local = fs.readFileSync(filePath, "utf8"); } catch { }
95
+
96
+ if (resolutionMethod === 'skip') {
97
+ // Do nothing, never create or update file
98
+ console.log(chalk.yellow(`⏭️ Skipped ${record.Name} (${record.Type}) due to conflict or mismatch.`));
99
+ continue;
100
+ }
101
+ else if (resolutionMethod === 'overwrite') {
102
+ // Always accept server version, creating file if missing
103
+ console.log(chalk.redBright(`⚠️ Overwriting local file: ${filePath} with server version.`));
104
+ finalContent = content;
105
+ }
106
+ else if (resolutionMethod === 'diff') {
107
+ // Use mergeFiles, show diff, ask user
108
+ let merged;
109
+ try {
110
+ merged = mergeFiles(baseContent, local, content);
111
+ } catch (e) {
112
+ console.log(chalk.red(`❌ Error merging: ${e.message}`));
113
+ continue;
114
+ }
115
+
116
+ // Attempt to open the diff in vs code
117
+ const randomStr = (length) => Math.random().toString(36).slice(2, 2 + length);
118
+ const tempFileServer = path.join(tmpdir(), `${randomStr(4)}.Server${filename}`);
119
+ const tempFileLocal = path.join(tmpdir(), `${randomStr(4)}.Local${filename}`);
120
+ fs.writeFileSync(tempFileServer, content);
121
+ fs.writeFileSync(tempFileLocal, local);
122
+
123
+ const vsCodeDiffDisplayed = openDiffInVSCode(tempFileServer, tempFileLocal);
124
+
125
+ // If the VS Code diff failed, show in terminal
126
+ if (!vsCodeDiffDisplayed) {
127
+ // Show diff (local vs merged)
128
+ const diff = diffLines(local, merged);
129
+ console.log(chalk.magenta.bold(`\n======= REVIEW MERGED for ${filename} =======`));
130
+ diff.forEach(part => {
131
+ const color = part.added ? 'green' :
132
+ part.removed ? 'red' : 'gray';
133
+ process.stdout.write(chalk[color](part.value));
134
+ });
135
+ console.log(chalk.magenta.bold("\n======= END REVIEW ======="));
136
+
137
+ // Warn if there are still unresolved conflict markers
138
+ if (merged.includes('<<<<<<<')) {
139
+ console.log(chalk.red.bold("\n⚠️ Merge conflict markers present. Please resolve manually after accepting."));
140
+ }
141
+ }
142
+
143
+ // Prompt user to accept or skip writing merged content
144
+ const choice = readlineSync.question(
145
+ chalk.yellow("\nAccept merged changes and write to file? (y/n): ")
146
+ );
147
+
148
+ // Delete the temp files
149
+ fs.unlinkSync(tempFileLocal);
150
+ fs.unlinkSync(tempFileServer);
151
+
152
+ if (choice.trim().toLowerCase() === 'y') {
153
+ finalContent = merged;
154
+ } else {
155
+ console.log(chalk.yellow("⏭️ Skipped due to user choice."));
156
+ continue;
157
+ }
158
+ }
159
+ else if (resolutionMethod === 'merge') {
160
+ // No prompt: always write merged result (may contain conflict markers)
161
+ let merged;
162
+
163
+ try {
164
+ merged = mergeFiles(baseContent, local, content);
165
+ } catch (e) {
166
+ console.log(chalk.red(`❌ Error merging: ${e.message}`));
167
+ continue;
168
+ }
169
+ if (merged.includes('<<<<<<<')) {
170
+ console.log(chalk.red.bold(`⚠️ Merge conflict markers written to ${filename}. Manual review required.`));
171
+ } else {
172
+ console.log(chalk.green(`✔️ Merged ${filename} automatically.`));
173
+ }
174
+ finalContent = merged;
175
+ }
176
+ else {
177
+ // Unknown or unsupported mode (should not occur)
178
+ console.log(chalk.red(`❌ Unknown resolution method: ${resolutionMethod}`));
179
+ continue;
180
+ }
181
+ }
182
+
183
+ // --- Actually Write File (if required by resolution) ---
184
+ if (finalContent !== null) {
185
+ try {
186
+ // Lookup a matching file dir in our cache, in case the file was renamed
187
+ const cachedFilePath = findFileByTag(record.Id) || path.resolve(filePath);
188
+
189
+ // Ensure the directory exists (paranoia for deeply nested files)
190
+ fs.mkdirSync(path.dirname(cachedFilePath), { recursive: true });
191
+ fs.writeFileSync(cachedFilePath, finalContent, "utf8");
192
+
193
+ // Add a file tag so we can keep track of file changes
194
+ await setFileTag(cachedFilePath, record.Id);
195
+
196
+ // Set mtime/atime for reproducible syncs (optional, best effort)
197
+ let mtimeMs = null;
198
+ if (record.ModifiedOn) {
199
+ mtimeMs = new Date(record.ModifiedOn).getTime();
200
+ } else if (record.CreatedOn) {
201
+ mtimeMs = new Date(record.CreatedOn).getTime();
202
+ }
203
+ if (mtimeMs) {
204
+ const mtimeSeconds = mtimeMs / 1000;
205
+ try {
206
+ fs.utimesSync(cachedFilePath, mtimeSeconds, mtimeSeconds);
207
+ } catch (e) {
208
+ console.warn(`⚠️ Could not set times for ${cachedFilePath}: ${e.message}`);
209
+ }
210
+ }
211
+
212
+
213
+ // If the server records content and the local files content match then we can update the base
214
+ if (sha256(finalContent) === sha256(record.Content)) {
215
+ // We still need to use the expected filePath as the rename might be local only
216
+ updateBase(filePath, record, cachedFilePath);
217
+ }
218
+ } catch (err) {
219
+ console.error(chalk.red(`❌ Failed to write file ${cachedFilePath}: ${err.message}`));
220
+ }
221
+ }
222
+ }
223
+ };