@uagents/syncenv-cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # SyncEnv CLI
2
+
3
+ CLI for SyncEnv - Secure environment variable synchronization.
4
+
5
+ ## Installation
6
+
7
+ ### Via npm (Recommended)
8
+
9
+ ```bash
10
+ npm install -g @uagents/syncenv-cli
11
+ ```
12
+
13
+ ### Via pnpm
14
+
15
+ ```bash
16
+ pnpm add -g @uagents/syncenv-cli
17
+ ```
18
+
19
+ ### Via curl (Standalone Binary)
20
+
21
+ ```bash
22
+ curl -sL https://syncenv-files.uagents.app/cli/install.sh | bash
23
+ ```
24
+
25
+ With custom install directory:
26
+
27
+ ```bash
28
+ curl -sL https://syncenv-files.uagents.app/cli/install.sh | INSTALL_DIR=~/.local/bin bash
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ 1. **Initialize a project:**
34
+
35
+ ```bash
36
+ syncenv init
37
+ ```
38
+
39
+ 2. **Create an account or login:**
40
+
41
+ ```bash
42
+ syncenv auth signup
43
+ # or
44
+ syncenv auth login
45
+ ```
46
+
47
+ 3. **Create a project on the server:**
48
+
49
+ ```bash
50
+ syncenv project create
51
+ ```
52
+
53
+ 4. **Push your .env file:**
54
+
55
+ ```bash
56
+ syncenv env push
57
+ ```
58
+
59
+ 5. **Pull environment variables:**
60
+ ```bash
61
+ syncenv env pull
62
+ ```
63
+
64
+ ## Commands
65
+
66
+ ### Authentication
67
+
68
+ - `syncenv auth signup` - Create a new account
69
+ - `syncenv auth login` - Login to your account
70
+ - `syncenv auth logout` - Logout and clear session
71
+ - `syncenv auth status` - Check authentication status
72
+
73
+ ### Project Management
74
+
75
+ - `syncenv init` - Initialize project configuration
76
+ - `syncenv project list` - List all projects
77
+ - `syncenv project create [name]` - Create a new project
78
+ - `syncenv project get <id>` - Get project details
79
+ - `syncenv project delete <id>` - Delete a project
80
+ - `syncenv project use <id>` - Set default project
81
+
82
+ ### Environment Variables
83
+
84
+ - `syncenv env push` - Push .env file to server
85
+ - `-p, --project <id>` - Project ID
86
+ - `-e, --env <name>` - Environment name
87
+ - `-f, --file <path>` - File path
88
+ - `-m, --message <message>` - Change description
89
+
90
+ - `syncenv env pull` - Pull .env file from server
91
+ - `-p, --project <id>` - Project ID
92
+ - `-e, --env <name>` - Environment name
93
+ - `-f, --file <path>` - Output file path
94
+ - `-v, --version <number>` - Specific version to pull
95
+ - `-m, --merge` - Merge with existing file
96
+
97
+ - `syncenv env history` - Show version history
98
+ - `-l, --limit <number>` - Number of versions to show
99
+
100
+ - `syncenv env diff <v1> <v2>` - Compare two versions
101
+
102
+ - `syncenv env rollback <version>` - Rollback to a specific version
103
+
104
+ - `syncenv env sync` - Smart sync (pull + merge + push)
105
+ - `--dry-run` - Preview changes without applying
106
+ - `--strategy <strategy>` - Conflict resolution: `local-wins`, `remote-wins`, `interactive`
107
+ - `-y, --yes` - Skip confirmation prompts
108
+
109
+ ### Device Management
110
+
111
+ - `syncenv device list` - List all devices
112
+ - `syncenv device authorize <id>` - Authorize a pending device
113
+ - `syncenv device revoke <id>` - Revoke a device
114
+ - `syncenv device remove <id>` - Remove a device
115
+ - `syncenv device current` - Show current device info
116
+
117
+ ### Diagnostics
118
+
119
+ - `syncenv doctor` - Diagnose configuration and connectivity issues
120
+
121
+ ## Smart Merge
122
+
123
+ When pushing or syncing, if remote has been modified since your last pull, the CLI automatically performs a **three-way merge**:
124
+
125
+ 1. **Detect conflicts** - Compare your local changes with remote changes
126
+ 2. **Auto-merge** - Non-conflicting changes are merged automatically
127
+ 3. **Interactive resolution** - Conflicts are presented with options:
128
+ - `[l]` Use local value
129
+ - `[r]` Use remote value
130
+ - `[b]` Keep both values
131
+ - `[e]` Edit custom value
132
+ - `[s]` Skip and keep conflict markers
133
+
134
+ ### Merge Strategies
135
+
136
+ ```bash
137
+ # Interactive (default) - prompt for each conflict
138
+ syncenv env sync
139
+
140
+ # Local wins - always use your changes
141
+ syncenv env sync --strategy=local-wins
142
+
143
+ # Remote wins - always use remote changes
144
+ syncenv env sync --strategy=remote-wins
145
+
146
+ # Fail on conflict - exit if conflicts exist
147
+ syncenv env push --strategy=fail-on-conflict
148
+ ```
149
+
150
+ ## Configuration
151
+
152
+ ### CLI Config
153
+
154
+ Stored in OS-specific config directory (e.g., `~/.config/syncenv/` on Linux).
155
+
156
+ - `apiUrl` - API base URL
157
+ - `authToken` - Authentication token
158
+ - `userId` - User ID
159
+ - `deviceId` - Device ID
160
+
161
+ ### Project Config (`.envsyncrc`)
162
+
163
+ Example:
164
+
165
+ ```yaml
166
+ project:
167
+ name: myapp
168
+ id: proj_xxx
169
+
170
+ defaults:
171
+ environment: dev
172
+ pushOnChange: false
173
+ confirmOverwrite: true
174
+
175
+ encryption:
176
+ algorithm: AES-256-GCM
177
+ keyDerivation: Argon2id
178
+
179
+ environments:
180
+ dev:
181
+ file: .env
182
+ staging:
183
+ file: .env.staging
184
+ production:
185
+ file: .env.production
186
+ requireConfirmation: true
187
+ ```
188
+
189
+ ## Environment Variables
190
+
191
+ - `SYNCENV_API_URL` - API base URL (default: http://localhost:8787)
192
+ - `SYNCENV_VERBOSE` - Enable verbose logging
193
+
194
+ ## Security
195
+
196
+ - All encryption/decryption happens locally on your device
197
+ - Master password never leaves your device
198
+ - Data Encryption Keys (DEK) are encrypted with your Key Encryption Key (KEK)
199
+ - KEK is derived from your master password using Argon2id
200
+ - Server only stores encrypted data and cannot decrypt your environment variables
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,403 @@
1
+ // src/merge/interactive.ts
2
+ import * as readline from "readline";
3
+
4
+ // src/merge/three-way.ts
5
+ function threeWayMerge(input) {
6
+ const { base, local, remote } = input;
7
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]);
8
+ const result = {};
9
+ const conflicts = [];
10
+ let added = 0;
11
+ let modified = 0;
12
+ let deleted = 0;
13
+ let unchanged = 0;
14
+ for (const key of allKeys) {
15
+ const baseVal = base[key] ?? null;
16
+ const localVal = local[key] ?? null;
17
+ const remoteVal = remote[key] ?? null;
18
+ if (localVal === remoteVal) {
19
+ if (localVal !== null) {
20
+ result[key] = localVal;
21
+ if (baseVal === null) {
22
+ added++;
23
+ } else if (baseVal !== localVal) {
24
+ modified++;
25
+ } else {
26
+ unchanged++;
27
+ }
28
+ } else if (baseVal !== null) {
29
+ deleted++;
30
+ }
31
+ continue;
32
+ }
33
+ if (localVal === baseVal) {
34
+ if (remoteVal !== null) {
35
+ result[key] = remoteVal;
36
+ if (baseVal === null) {
37
+ added++;
38
+ } else {
39
+ modified++;
40
+ }
41
+ } else {
42
+ deleted++;
43
+ }
44
+ continue;
45
+ }
46
+ if (remoteVal === baseVal) {
47
+ if (localVal !== null) {
48
+ result[key] = localVal;
49
+ if (baseVal === null) {
50
+ added++;
51
+ } else {
52
+ modified++;
53
+ }
54
+ } else {
55
+ deleted++;
56
+ }
57
+ continue;
58
+ }
59
+ conflicts.push({
60
+ key,
61
+ baseValue: baseVal,
62
+ localValue: localVal,
63
+ remoteValue: remoteVal
64
+ });
65
+ }
66
+ return {
67
+ autoMerged: conflicts.length === 0,
68
+ mergedContent: formatEnvFile(result),
69
+ conflicts,
70
+ statistics: {
71
+ added,
72
+ modified,
73
+ deleted,
74
+ conflicts: conflicts.length,
75
+ unchanged
76
+ }
77
+ };
78
+ }
79
+ function resolveConflicts(base, local, remote, conflicts) {
80
+ const result = {};
81
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]);
82
+ for (const key of allKeys) {
83
+ const baseVal = base[key] ?? null;
84
+ const localVal = local[key] ?? null;
85
+ const remoteVal = remote[key] ?? null;
86
+ const conflict = conflicts.find((c) => c.key === key);
87
+ if (conflict) {
88
+ switch (conflict.resolution) {
89
+ case "local":
90
+ if (localVal !== null) result[key] = localVal;
91
+ break;
92
+ case "remote":
93
+ if (remoteVal !== null) result[key] = remoteVal;
94
+ break;
95
+ case "custom":
96
+ if (conflict.customValue !== void 0 && conflict.customValue !== null) {
97
+ result[key] = conflict.customValue;
98
+ }
99
+ break;
100
+ default:
101
+ break;
102
+ }
103
+ continue;
104
+ }
105
+ if (localVal === remoteVal) {
106
+ if (localVal !== null) result[key] = localVal;
107
+ } else if (localVal === baseVal) {
108
+ if (remoteVal !== null) result[key] = remoteVal;
109
+ } else {
110
+ if (localVal !== null) result[key] = localVal;
111
+ }
112
+ }
113
+ const unresolvedConflicts = conflicts.filter((c) => !c.resolution);
114
+ if (unresolvedConflicts.length > 0) {
115
+ return formatEnvFileWithConflicts(result, unresolvedConflicts);
116
+ }
117
+ return formatEnvFile(result);
118
+ }
119
+ function formatEnvFile(vars) {
120
+ const lines = [];
121
+ for (const [key, value] of Object.entries(vars)) {
122
+ if (value.includes("\n") || value.includes('"')) {
123
+ if (!value.includes("'")) {
124
+ lines.push(`${key}='${value}'`);
125
+ } else {
126
+ lines.push(`${key}="${value.replace(/"/g, '\\"')}"`);
127
+ }
128
+ } else if (value.includes(" ") || value.includes("#")) {
129
+ lines.push(`${key}="${value}"`);
130
+ } else {
131
+ lines.push(`${key}=${value}`);
132
+ }
133
+ }
134
+ return lines.join("\n") + (lines.length > 0 ? "\n" : "");
135
+ }
136
+ function formatEnvFileWithConflicts(vars, conflicts) {
137
+ const lines = ["# WARNING: This file contains unresolved merge conflicts", ""];
138
+ for (const [key, value] of Object.entries(vars)) {
139
+ const conflict = conflicts.find((c) => c.key === key);
140
+ if (conflict) {
141
+ lines.push(`# CONFLICT: ${key}`);
142
+ lines.push(`# BASE: ${conflict.baseValue ?? "(deleted)"}`);
143
+ lines.push(`${key}=<<<<<<< LOCAL`);
144
+ lines.push(conflict.localValue ?? "");
145
+ lines.push("=======");
146
+ lines.push(conflict.remoteValue ?? "");
147
+ lines.push(">>>>>>> REMOTE");
148
+ lines.push("");
149
+ } else {
150
+ lines.push(`${key}=${value}`);
151
+ }
152
+ }
153
+ return lines.join("\n") + "\n";
154
+ }
155
+ function applyMergeStrategy(input, strategy) {
156
+ const result = threeWayMerge(input);
157
+ if (result.autoMerged) {
158
+ return result;
159
+ }
160
+ if (strategy === "fail-on-conflict") {
161
+ throw new Error(
162
+ `Merge failed: ${result.conflicts.length} conflict(s) found. Keys: ${result.conflicts.map((c) => c.key).join(", ")}`
163
+ );
164
+ }
165
+ const resolvedConflicts = result.conflicts.map((c) => ({
166
+ ...c,
167
+ resolution: strategy === "local-wins" ? "local" : "remote"
168
+ }));
169
+ const resolvedContent = resolveConflicts(
170
+ input.base,
171
+ input.local,
172
+ input.remote,
173
+ resolvedConflicts
174
+ );
175
+ return {
176
+ autoMerged: true,
177
+ mergedContent: resolvedContent,
178
+ conflicts: resolvedConflicts,
179
+ statistics: {
180
+ ...result.statistics,
181
+ conflicts: 0
182
+ }
183
+ };
184
+ }
185
+
186
+ // src/merge/interactive.ts
187
+ var COLORS = {
188
+ reset: "\x1B[0m",
189
+ red: "\x1B[31m",
190
+ green: "\x1B[32m",
191
+ yellow: "\x1B[33m",
192
+ blue: "\x1B[34m",
193
+ magenta: "\x1B[35m",
194
+ cyan: "\x1B[36m",
195
+ gray: "\x1B[90m"
196
+ };
197
+ async function interactiveMerge(input, mergeResult, options = {}) {
198
+ const { base, local, remote } = input;
199
+ const { conflicts, statistics } = mergeResult;
200
+ printMergeSummary(statistics);
201
+ const resolvedConflicts = [];
202
+ for (const conflict of conflicts) {
203
+ printConflictDetails(conflict);
204
+ const choice = await promptForResolution(conflict, options);
205
+ if (choice.type === "cancel") {
206
+ return { success: false, content: "", resolvedConflicts, cancelled: true };
207
+ }
208
+ if (choice.type === "save-draft") {
209
+ resolvedConflicts.push(
210
+ ...conflicts.slice(resolvedConflicts.length).map((c) => ({
211
+ ...c,
212
+ resolution: void 0
213
+ }))
214
+ );
215
+ const content2 = resolveConflicts(base, local, remote, resolvedConflicts);
216
+ return { success: true, content: content2, resolvedConflicts, cancelled: false };
217
+ }
218
+ resolvedConflicts.push({
219
+ ...conflict,
220
+ resolution: choice.resolution,
221
+ customValue: choice.customValue
222
+ });
223
+ }
224
+ const content = resolveConflicts(base, local, remote, resolvedConflicts);
225
+ const confirmed = await confirmFinalMerge(mergeResult.statistics);
226
+ if (!confirmed) {
227
+ return { success: false, content: "", resolvedConflicts, cancelled: true };
228
+ }
229
+ return { success: true, content, resolvedConflicts, cancelled: false };
230
+ }
231
+ function printMergeSummary(stats) {
232
+ console.log("");
233
+ console.log(
234
+ `${COLORS.yellow}\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510${COLORS.reset}`
235
+ );
236
+ console.log(
237
+ `${COLORS.yellow}\u2502${COLORS.reset} ${COLORS.yellow}\u26A0\uFE0F Conflicts detected, manual resolution required${COLORS.reset} ${COLORS.yellow}\u2502${COLORS.reset}`
238
+ );
239
+ console.log(
240
+ `${COLORS.yellow}\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524${COLORS.reset}`
241
+ );
242
+ if (stats.added > 0) {
243
+ console.log(
244
+ `${COLORS.yellow}\u2502${COLORS.reset} ${COLORS.green}Added:${COLORS.reset} ${stats.added.toString().padStart(3)}${" ".repeat(42)}${COLORS.yellow}\u2502${COLORS.reset}`
245
+ );
246
+ }
247
+ if (stats.modified > 0) {
248
+ console.log(
249
+ `${COLORS.yellow}\u2502${COLORS.reset} ${COLORS.cyan}Modified:${COLORS.reset} ${stats.modified.toString().padStart(3)}${" ".repeat(39)}${COLORS.yellow}\u2502${COLORS.reset}`
250
+ );
251
+ }
252
+ if (stats.deleted > 0) {
253
+ console.log(
254
+ `${COLORS.yellow}\u2502${COLORS.reset} ${COLORS.red}Deleted:${COLORS.reset} ${stats.deleted.toString().padStart(3)}${" ".repeat(40)}${COLORS.yellow}\u2502${COLORS.reset}`
255
+ );
256
+ }
257
+ console.log(
258
+ `${COLORS.yellow}\u2502${COLORS.reset} ${COLORS.red}\u2715 Conflicts: ${stats.conflicts}${" ".repeat(35)}${COLORS.yellow}\u2502${COLORS.reset}`
259
+ );
260
+ console.log(
261
+ `${COLORS.yellow}\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518${COLORS.reset}`
262
+ );
263
+ console.log("");
264
+ }
265
+ function printConflictDetails(conflict) {
266
+ const { key, baseValue, localValue, remoteValue } = conflict;
267
+ console.log(`${COLORS.magenta}Conflict: ${key}${COLORS.reset}`);
268
+ console.log(
269
+ `${COLORS.gray}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${COLORS.reset}`
270
+ );
271
+ if (baseValue !== null) {
272
+ console.log(`${COLORS.gray}Base value:${COLORS.reset}`);
273
+ console.log(` ${truncate(baseValue, 60)}`);
274
+ } else {
275
+ console.log(`${COLORS.gray}Base: (new variable)${COLORS.reset}`);
276
+ }
277
+ console.log("");
278
+ if (localValue !== null) {
279
+ console.log(`${COLORS.green}[L] Local:${COLORS.reset}`);
280
+ console.log(` ${truncate(localValue, 60)}`);
281
+ } else {
282
+ console.log(`${COLORS.green}[L] Local: (deleted)${COLORS.reset}`);
283
+ }
284
+ console.log("");
285
+ if (remoteValue !== null) {
286
+ console.log(`${COLORS.blue}[R] Remote:${COLORS.reset}`);
287
+ console.log(` ${truncate(remoteValue, 60)}`);
288
+ } else {
289
+ console.log(`${COLORS.blue}[R] Remote: (deleted)${COLORS.reset}`);
290
+ }
291
+ console.log("");
292
+ }
293
+ async function promptForResolution(conflict, options) {
294
+ const rl = readline.createInterface({
295
+ input: process.stdin,
296
+ output: process.stdout
297
+ });
298
+ const ask = (prompt) => {
299
+ return new Promise((resolve) => {
300
+ rl.question(prompt, resolve);
301
+ });
302
+ };
303
+ try {
304
+ while (true) {
305
+ const choices = [];
306
+ if (conflict.localValue !== null) {
307
+ choices.push("[l] Use local value (Local)");
308
+ }
309
+ if (conflict.remoteValue !== null) {
310
+ choices.push("[r] Use remote value (Remote)");
311
+ }
312
+ if (options.allowBoth && conflict.localValue !== null && conflict.remoteValue !== null) {
313
+ choices.push("[b] Keep both (Both)");
314
+ }
315
+ if (options.allowEdit) {
316
+ choices.push("[e] Edit custom value (Edit)");
317
+ }
318
+ choices.push("[s] Skip, keep conflict markers (Skip)");
319
+ choices.push("[d] Save draft, continue later (Draft)");
320
+ choices.push("[q] Cancel merge (Cancel)");
321
+ console.log("How would you like to resolve this conflict?");
322
+ choices.forEach((c) => console.log(` ${c}`));
323
+ console.log("");
324
+ const answer = await ask("Choice: ");
325
+ const choice = answer.trim().toLowerCase();
326
+ switch (choice) {
327
+ case "l":
328
+ case "local":
329
+ if (conflict.localValue !== null) {
330
+ return { type: "resolve", resolution: "local" };
331
+ }
332
+ console.log(`${COLORS.red}Local value does not exist, cannot select${COLORS.reset}`);
333
+ break;
334
+ case "r":
335
+ case "remote":
336
+ if (conflict.remoteValue !== null) {
337
+ return { type: "resolve", resolution: "remote" };
338
+ }
339
+ console.log(`${COLORS.red}Remote value does not exist, cannot select${COLORS.reset}`);
340
+ break;
341
+ case "b":
342
+ case "both":
343
+ if (conflict.localValue !== null && conflict.remoteValue !== null) {
344
+ const customValue = `${conflict.localValue}
345
+ # MERGED_FROM_REMOTE: ${conflict.remoteValue}`;
346
+ return { type: "resolve", resolution: "custom", customValue };
347
+ }
348
+ console.log(`${COLORS.red}Both values must exist to select this option${COLORS.reset}`);
349
+ break;
350
+ case "e":
351
+ case "edit":
352
+ if (options.allowEdit) {
353
+ const customValue = await ask("Enter custom value: ");
354
+ return { type: "resolve", resolution: "local", customValue };
355
+ }
356
+ break;
357
+ case "s":
358
+ case "skip":
359
+ return { type: "resolve", resolution: "local" };
360
+ case "d":
361
+ case "draft":
362
+ return { type: "save-draft" };
363
+ case "q":
364
+ case "quit":
365
+ case "cancel":
366
+ return { type: "cancel" };
367
+ default:
368
+ console.log(`${COLORS.red}Invalid option, please select again${COLORS.reset}`);
369
+ }
370
+ }
371
+ } finally {
372
+ rl.close();
373
+ }
374
+ }
375
+ async function confirmFinalMerge(stats) {
376
+ const rl = readline.createInterface({
377
+ input: process.stdin,
378
+ output: process.stdout
379
+ });
380
+ return new Promise((resolve) => {
381
+ console.log("");
382
+ console.log(`${COLORS.cyan}Merge Summary:${COLORS.reset}`);
383
+ console.log(` Added: ${stats.added}`);
384
+ console.log(` Modified: ${stats.modified}`);
385
+ console.log(` Deleted: ${stats.deleted}`);
386
+ console.log(` Conflicts resolved: ${stats.conflicts}`);
387
+ console.log("");
388
+ rl.question("Apply this merge? [y/N]: ", (answer) => {
389
+ rl.close();
390
+ resolve(answer.trim().toLowerCase() === "y");
391
+ });
392
+ });
393
+ }
394
+ function truncate(str, maxLength) {
395
+ if (str.length <= maxLength) return str;
396
+ return str.substring(0, maxLength - 3) + "...";
397
+ }
398
+
399
+ export {
400
+ threeWayMerge,
401
+ applyMergeStrategy,
402
+ interactiveMerge
403
+ };