@fern-api/replay 0.8.0 → 0.9.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/dist/cli.cjs +496 -127
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +250 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -17
- package/dist/index.d.ts +62 -17
- package/dist/index.js +250 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -142,7 +142,7 @@ function isReplayCommit(commit) {
|
|
|
142
142
|
return commit.message.startsWith("[fern-replay]");
|
|
143
143
|
}
|
|
144
144
|
function isRevertCommit(message) {
|
|
145
|
-
return
|
|
145
|
+
return /^Revert ".+"$/.test(message);
|
|
146
146
|
}
|
|
147
147
|
function parseRevertedSha(fullBody) {
|
|
148
148
|
const match = fullBody.match(/This reverts commit ([0-9a-f]{40})\./);
|
|
@@ -243,6 +243,15 @@ var LockfileManager = class {
|
|
|
243
243
|
this.ensureLoaded();
|
|
244
244
|
this.lock.patches = [];
|
|
245
245
|
}
|
|
246
|
+
addForgottenHash(hash) {
|
|
247
|
+
this.ensureLoaded();
|
|
248
|
+
if (!this.lock.forgotten_hashes) {
|
|
249
|
+
this.lock.forgotten_hashes = [];
|
|
250
|
+
}
|
|
251
|
+
if (!this.lock.forgotten_hashes.includes(hash)) {
|
|
252
|
+
this.lock.forgotten_hashes.push(hash);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
246
255
|
getUnresolvedPatches() {
|
|
247
256
|
this.ensureLoaded();
|
|
248
257
|
return this.lock.patches.filter((p) => p.status === "unresolved");
|
|
@@ -339,6 +348,7 @@ var ReplayDetector = class {
|
|
|
339
348
|
}
|
|
340
349
|
const commits = this.parseGitLog(log);
|
|
341
350
|
const newPatches = [];
|
|
351
|
+
const forgottenHashes = new Set(lock.forgotten_hashes ?? []);
|
|
342
352
|
for (const commit of commits) {
|
|
343
353
|
if (isGenerationCommit(commit)) {
|
|
344
354
|
continue;
|
|
@@ -352,7 +362,7 @@ var ReplayDetector = class {
|
|
|
352
362
|
}
|
|
353
363
|
const patchContent = await this.git.formatPatch(commit.sha);
|
|
354
364
|
const contentHash = this.computeContentHash(patchContent);
|
|
355
|
-
if (lock.patches.find((p) => p.content_hash === contentHash)) {
|
|
365
|
+
if (lock.patches.find((p) => p.content_hash === contentHash) || forgottenHashes.has(contentHash)) {
|
|
356
366
|
continue;
|
|
357
367
|
}
|
|
358
368
|
const filesOutput = await this.git.exec(["diff-tree", "--no-commit-id", "--name-only", "-r", commit.sha]);
|
|
@@ -422,6 +432,9 @@ var ReplayDetector = class {
|
|
|
422
432
|
revertIndicesToRemove.add(idx);
|
|
423
433
|
}
|
|
424
434
|
}
|
|
435
|
+
if (!matchedExisting && !matchedNew) {
|
|
436
|
+
revertIndicesToRemove.add(i);
|
|
437
|
+
}
|
|
425
438
|
}
|
|
426
439
|
const filteredPatches = newPatches.filter((_, i) => !revertIndicesToRemove.has(i));
|
|
427
440
|
return { patches: filteredPatches, revertedPatchIds: [...revertedPatchIdSet] };
|
|
@@ -448,7 +461,7 @@ var ReplayDetector = class {
|
|
|
448
461
|
if (!diff.trim()) return { patches: [], revertedPatchIds: [] };
|
|
449
462
|
const contentHash = this.computeContentHash(diff);
|
|
450
463
|
const lock = this.lockManager.read();
|
|
451
|
-
if (lock.patches.some((p) => p.content_hash === contentHash)) {
|
|
464
|
+
if (lock.patches.some((p) => p.content_hash === contentHash) || (lock.forgotten_hashes ?? []).includes(contentHash)) {
|
|
452
465
|
return { patches: [], revertedPatchIds: [] };
|
|
453
466
|
}
|
|
454
467
|
const headSha = (await this.git.exec(["rev-parse", "HEAD"])).trim();
|
|
@@ -518,6 +531,36 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
|
518
531
|
import { tmpdir } from "os";
|
|
519
532
|
import { dirname as dirname2, extname, join as join2 } from "path";
|
|
520
533
|
import { minimatch } from "minimatch";
|
|
534
|
+
|
|
535
|
+
// src/conflict-utils.ts
|
|
536
|
+
function stripConflictMarkers(content) {
|
|
537
|
+
const lines = content.split("\n");
|
|
538
|
+
const result = [];
|
|
539
|
+
let inConflict = false;
|
|
540
|
+
let inOurs = false;
|
|
541
|
+
for (const line of lines) {
|
|
542
|
+
if (line.startsWith("<<<<<<< ")) {
|
|
543
|
+
inConflict = true;
|
|
544
|
+
inOurs = true;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (inConflict && line === "=======") {
|
|
548
|
+
inOurs = false;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (inConflict && line.startsWith(">>>>>>> ")) {
|
|
552
|
+
inConflict = false;
|
|
553
|
+
inOurs = false;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (!inConflict || inOurs) {
|
|
557
|
+
result.push(line);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return result.join("\n");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/ReplayApplicator.ts
|
|
521
564
|
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
522
565
|
".png",
|
|
523
566
|
".jpg",
|
|
@@ -589,7 +632,8 @@ var ReplayApplicator = class {
|
|
|
589
632
|
async applyPatches(patches) {
|
|
590
633
|
this.resetAccumulator();
|
|
591
634
|
const results = [];
|
|
592
|
-
for (
|
|
635
|
+
for (let i = 0; i < patches.length; i++) {
|
|
636
|
+
const patch = patches[i];
|
|
593
637
|
if (this.isExcluded(patch)) {
|
|
594
638
|
results.push({
|
|
595
639
|
patch,
|
|
@@ -600,6 +644,33 @@ var ReplayApplicator = class {
|
|
|
600
644
|
}
|
|
601
645
|
const result = await this.applyPatchWithFallback(patch);
|
|
602
646
|
results.push(result);
|
|
647
|
+
if (result.status === "conflict" && result.fileResults) {
|
|
648
|
+
const laterFiles = /* @__PURE__ */ new Set();
|
|
649
|
+
for (let j = i + 1; j < patches.length; j++) {
|
|
650
|
+
for (const f of patches[j].files) {
|
|
651
|
+
laterFiles.add(f);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const resolvedToOriginal = /* @__PURE__ */ new Map();
|
|
655
|
+
if (result.resolvedFiles) {
|
|
656
|
+
for (const [orig, resolved] of Object.entries(result.resolvedFiles)) {
|
|
657
|
+
resolvedToOriginal.set(resolved, orig);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const fileResult of result.fileResults) {
|
|
661
|
+
if (fileResult.status !== "conflict") continue;
|
|
662
|
+
const originalPath = resolvedToOriginal.get(fileResult.file) ?? fileResult.file;
|
|
663
|
+
if (laterFiles.has(fileResult.file) || laterFiles.has(originalPath)) {
|
|
664
|
+
const filePath = join2(this.outputDir, fileResult.file);
|
|
665
|
+
try {
|
|
666
|
+
const content = await readFile(filePath, "utf-8");
|
|
667
|
+
const stripped = stripConflictMarkers(content);
|
|
668
|
+
await writeFile(filePath, stripped);
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
603
674
|
}
|
|
604
675
|
return results;
|
|
605
676
|
}
|
|
@@ -618,7 +689,7 @@ var ReplayApplicator = class {
|
|
|
618
689
|
const resolvedPath = await this.resolveFilePath(filePath, baseGen.tree_hash, currentTreeHash);
|
|
619
690
|
const base = await this.git.showFile(baseGen.tree_hash, filePath);
|
|
620
691
|
const theirs = await this.applyPatchToContent(base, patch.patch_content, filePath, tempGit, tempDir);
|
|
621
|
-
if (theirs
|
|
692
|
+
if (theirs) {
|
|
622
693
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
623
694
|
content: theirs,
|
|
624
695
|
baseGeneration: patch.base_generation
|
|
@@ -750,7 +821,7 @@ var ReplayApplicator = class {
|
|
|
750
821
|
);
|
|
751
822
|
let useAccumulatorAsMergeBase = false;
|
|
752
823
|
const accumulatorEntry = this.fileTheirsAccumulator.get(resolvedPath);
|
|
753
|
-
if (!theirs &&
|
|
824
|
+
if (!theirs && accumulatorEntry) {
|
|
754
825
|
theirs = await this.applyPatchToContent(
|
|
755
826
|
accumulatorEntry.content,
|
|
756
827
|
patch.patch_content,
|
|
@@ -809,7 +880,7 @@ var ReplayApplicator = class {
|
|
|
809
880
|
reason: "missing-content"
|
|
810
881
|
};
|
|
811
882
|
}
|
|
812
|
-
if (!base || !ours) {
|
|
883
|
+
if (!base && !useAccumulatorAsMergeBase || !ours) {
|
|
813
884
|
return {
|
|
814
885
|
file: resolvedPath,
|
|
815
886
|
status: "skipped",
|
|
@@ -817,11 +888,18 @@ var ReplayApplicator = class {
|
|
|
817
888
|
};
|
|
818
889
|
}
|
|
819
890
|
const mergeBase = useAccumulatorAsMergeBase && accumulatorEntry ? accumulatorEntry.content : base;
|
|
891
|
+
if (mergeBase == null) {
|
|
892
|
+
return {
|
|
893
|
+
file: resolvedPath,
|
|
894
|
+
status: "skipped",
|
|
895
|
+
reason: "missing-content"
|
|
896
|
+
};
|
|
897
|
+
}
|
|
820
898
|
const merged = threeWayMerge(mergeBase, ours, effective_theirs);
|
|
821
899
|
const outDir = dirname2(oursPath);
|
|
822
900
|
await mkdir(outDir, { recursive: true });
|
|
823
901
|
await writeFile(oursPath, merged.content);
|
|
824
|
-
if (effective_theirs
|
|
902
|
+
if (effective_theirs) {
|
|
825
903
|
this.fileTheirsAccumulator.set(resolvedPath, {
|
|
826
904
|
content: effective_theirs,
|
|
827
905
|
baseGeneration: patch.base_generation
|
|
@@ -1064,36 +1142,6 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync
|
|
|
1064
1142
|
import { join as join3 } from "path";
|
|
1065
1143
|
import { minimatch as minimatch2 } from "minimatch";
|
|
1066
1144
|
init_GitClient();
|
|
1067
|
-
|
|
1068
|
-
// src/conflict-utils.ts
|
|
1069
|
-
function stripConflictMarkers(content) {
|
|
1070
|
-
const lines = content.split("\n");
|
|
1071
|
-
const result = [];
|
|
1072
|
-
let inConflict = false;
|
|
1073
|
-
let inOurs = false;
|
|
1074
|
-
for (const line of lines) {
|
|
1075
|
-
if (line.startsWith("<<<<<<< ")) {
|
|
1076
|
-
inConflict = true;
|
|
1077
|
-
inOurs = true;
|
|
1078
|
-
continue;
|
|
1079
|
-
}
|
|
1080
|
-
if (inConflict && line === "=======") {
|
|
1081
|
-
inOurs = false;
|
|
1082
|
-
continue;
|
|
1083
|
-
}
|
|
1084
|
-
if (inConflict && line.startsWith(">>>>>>> ")) {
|
|
1085
|
-
inConflict = false;
|
|
1086
|
-
inOurs = false;
|
|
1087
|
-
continue;
|
|
1088
|
-
}
|
|
1089
|
-
if (!inConflict || inOurs) {
|
|
1090
|
-
result.push(line);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
return result.join("\n");
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// src/ReplayService.ts
|
|
1097
1145
|
var ReplayService = class {
|
|
1098
1146
|
git;
|
|
1099
1147
|
detector;
|
|
@@ -2061,30 +2109,145 @@ function computeContentHash(patchContent) {
|
|
|
2061
2109
|
|
|
2062
2110
|
// src/commands/forget.ts
|
|
2063
2111
|
import { minimatch as minimatch4 } from "minimatch";
|
|
2064
|
-
function
|
|
2112
|
+
function parseDiffStat(patchContent) {
|
|
2113
|
+
let additions = 0;
|
|
2114
|
+
let deletions = 0;
|
|
2115
|
+
let inDiffHunk = false;
|
|
2116
|
+
for (const line of patchContent.split("\n")) {
|
|
2117
|
+
if (line.startsWith("diff --git ")) {
|
|
2118
|
+
inDiffHunk = true;
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
if (!inDiffHunk) continue;
|
|
2122
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
2123
|
+
additions++;
|
|
2124
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
2125
|
+
deletions++;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
return { additions, deletions };
|
|
2129
|
+
}
|
|
2130
|
+
function toMatchedPatch(patch) {
|
|
2131
|
+
return {
|
|
2132
|
+
id: patch.id,
|
|
2133
|
+
message: patch.original_message,
|
|
2134
|
+
files: patch.files,
|
|
2135
|
+
diffstat: parseDiffStat(patch.patch_content),
|
|
2136
|
+
...patch.status ? { status: patch.status } : {}
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
function matchesPatch(patch, pattern) {
|
|
2140
|
+
const fileMatch = patch.files.some(
|
|
2141
|
+
(file) => file === pattern || minimatch4(file, pattern)
|
|
2142
|
+
);
|
|
2143
|
+
if (fileMatch) return true;
|
|
2144
|
+
return patch.original_message.toLowerCase().includes(pattern.toLowerCase());
|
|
2145
|
+
}
|
|
2146
|
+
function buildWarnings(patches) {
|
|
2147
|
+
const warnings = [];
|
|
2148
|
+
for (const patch of patches) {
|
|
2149
|
+
if (patch.status === "resolving") {
|
|
2150
|
+
warnings.push(
|
|
2151
|
+
`patch ${patch.id} has conflict markers in these files: ${patch.files.join(", ")}. Run \`git checkout -- <files>\` to restore the generated versions.`
|
|
2152
|
+
);
|
|
2153
|
+
} else if (patch.status === "unresolved") {
|
|
2154
|
+
warnings.push(
|
|
2155
|
+
`patch ${patch.id} had unresolved conflicts (files: ${patch.files.join(", ")}).`
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
return warnings;
|
|
2160
|
+
}
|
|
2161
|
+
var EMPTY_RESULT = {
|
|
2162
|
+
initialized: false,
|
|
2163
|
+
removed: [],
|
|
2164
|
+
remaining: 0,
|
|
2165
|
+
notFound: false,
|
|
2166
|
+
alreadyForgotten: [],
|
|
2167
|
+
totalPatches: 0,
|
|
2168
|
+
warnings: []
|
|
2169
|
+
};
|
|
2170
|
+
function forget(outputDir, options) {
|
|
2065
2171
|
const lockManager = new LockfileManager(outputDir);
|
|
2066
2172
|
if (!lockManager.exists()) {
|
|
2067
|
-
return {
|
|
2173
|
+
return { ...EMPTY_RESULT };
|
|
2068
2174
|
}
|
|
2069
2175
|
const lock = lockManager.read();
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2176
|
+
const totalPatches = lock.patches.length;
|
|
2177
|
+
if (options?.all) {
|
|
2178
|
+
const removed = lock.patches.map(toMatchedPatch);
|
|
2179
|
+
const warnings = buildWarnings(lock.patches);
|
|
2180
|
+
if (!options.dryRun) {
|
|
2181
|
+
for (const patch of lock.patches) {
|
|
2182
|
+
lockManager.addForgottenHash(patch.content_hash);
|
|
2183
|
+
}
|
|
2184
|
+
lockManager.clearPatches();
|
|
2185
|
+
lockManager.save();
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
initialized: true,
|
|
2189
|
+
removed,
|
|
2190
|
+
remaining: 0,
|
|
2191
|
+
notFound: false,
|
|
2192
|
+
alreadyForgotten: [],
|
|
2193
|
+
totalPatches,
|
|
2194
|
+
warnings
|
|
2195
|
+
};
|
|
2075
2196
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2197
|
+
if (options?.patchIds && options.patchIds.length > 0) {
|
|
2198
|
+
const removed = [];
|
|
2199
|
+
const alreadyForgotten = [];
|
|
2200
|
+
const patchesToRemove = [];
|
|
2201
|
+
for (const id of options.patchIds) {
|
|
2202
|
+
const patch = lock.patches.find((p) => p.id === id);
|
|
2203
|
+
if (patch) {
|
|
2204
|
+
removed.push(toMatchedPatch(patch));
|
|
2205
|
+
patchesToRemove.push(patch);
|
|
2206
|
+
} else {
|
|
2207
|
+
alreadyForgotten.push(id);
|
|
2208
|
+
}
|
|
2084
2209
|
}
|
|
2085
|
-
|
|
2210
|
+
const warnings = buildWarnings(patchesToRemove);
|
|
2211
|
+
if (!options.dryRun) {
|
|
2212
|
+
for (const patch of patchesToRemove) {
|
|
2213
|
+
lockManager.addForgottenHash(patch.content_hash);
|
|
2214
|
+
lockManager.removePatch(patch.id);
|
|
2215
|
+
}
|
|
2216
|
+
lockManager.save();
|
|
2217
|
+
}
|
|
2218
|
+
return {
|
|
2219
|
+
initialized: true,
|
|
2220
|
+
removed,
|
|
2221
|
+
remaining: totalPatches - removed.length,
|
|
2222
|
+
notFound: removed.length === 0 && alreadyForgotten.length > 0,
|
|
2223
|
+
alreadyForgotten,
|
|
2224
|
+
totalPatches,
|
|
2225
|
+
warnings
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
if (options?.pattern) {
|
|
2229
|
+
const matched = lock.patches.filter((p) => matchesPatch(p, options.pattern)).map(toMatchedPatch);
|
|
2230
|
+
return {
|
|
2231
|
+
initialized: true,
|
|
2232
|
+
removed: [],
|
|
2233
|
+
remaining: totalPatches,
|
|
2234
|
+
notFound: matched.length === 0,
|
|
2235
|
+
alreadyForgotten: [],
|
|
2236
|
+
totalPatches,
|
|
2237
|
+
warnings: [],
|
|
2238
|
+
matched
|
|
2239
|
+
};
|
|
2086
2240
|
}
|
|
2087
|
-
return {
|
|
2241
|
+
return {
|
|
2242
|
+
initialized: true,
|
|
2243
|
+
removed: [],
|
|
2244
|
+
remaining: totalPatches,
|
|
2245
|
+
notFound: totalPatches === 0,
|
|
2246
|
+
alreadyForgotten: [],
|
|
2247
|
+
totalPatches,
|
|
2248
|
+
warnings: [],
|
|
2249
|
+
matched: lock.patches.map(toMatchedPatch)
|
|
2250
|
+
};
|
|
2088
2251
|
}
|
|
2089
2252
|
|
|
2090
2253
|
// src/commands/reset.ts
|
|
@@ -2206,24 +2369,49 @@ async function getChangedFiles(git, currentGen, files) {
|
|
|
2206
2369
|
function status(outputDir) {
|
|
2207
2370
|
const lockManager = new LockfileManager(outputDir);
|
|
2208
2371
|
if (!lockManager.exists()) {
|
|
2209
|
-
return {
|
|
2372
|
+
return {
|
|
2373
|
+
initialized: false,
|
|
2374
|
+
generationCount: 0,
|
|
2375
|
+
lastGeneration: void 0,
|
|
2376
|
+
patches: [],
|
|
2377
|
+
unresolvedCount: 0,
|
|
2378
|
+
excludePatterns: []
|
|
2379
|
+
};
|
|
2210
2380
|
}
|
|
2211
2381
|
const lock = lockManager.read();
|
|
2212
2382
|
const patches = lock.patches.map((patch) => ({
|
|
2213
|
-
|
|
2214
|
-
|
|
2383
|
+
id: patch.id,
|
|
2384
|
+
type: patch.patch_content.includes("new file mode") ? "added" : "modified",
|
|
2215
2385
|
message: patch.original_message,
|
|
2216
|
-
|
|
2386
|
+
author: patch.original_author.split("<")[0]?.trim() || "unknown",
|
|
2387
|
+
sha: patch.original_commit.slice(0, 7),
|
|
2388
|
+
files: patch.files,
|
|
2389
|
+
fileCount: patch.files.length,
|
|
2390
|
+
...patch.status ? { status: patch.status } : {}
|
|
2217
2391
|
}));
|
|
2392
|
+
const unresolvedCount = lock.patches.filter(
|
|
2393
|
+
(p) => p.status === "unresolved" || p.status === "resolving"
|
|
2394
|
+
).length;
|
|
2218
2395
|
let lastGeneration;
|
|
2219
2396
|
const lastGen = lock.generations.find((g) => g.commit_sha === lock.current_generation);
|
|
2220
2397
|
if (lastGen) {
|
|
2221
2398
|
lastGeneration = {
|
|
2222
|
-
sha: lastGen.commit_sha,
|
|
2223
|
-
timestamp: lastGen.timestamp
|
|
2399
|
+
sha: lastGen.commit_sha.slice(0, 7),
|
|
2400
|
+
timestamp: lastGen.timestamp,
|
|
2401
|
+
cliVersion: lastGen.cli_version,
|
|
2402
|
+
generatorVersions: lastGen.generator_versions
|
|
2224
2403
|
};
|
|
2225
2404
|
}
|
|
2226
|
-
|
|
2405
|
+
const config = lockManager.getCustomizationsConfig();
|
|
2406
|
+
const excludePatterns = config.exclude ?? [];
|
|
2407
|
+
return {
|
|
2408
|
+
initialized: true,
|
|
2409
|
+
generationCount: lock.generations.length,
|
|
2410
|
+
lastGeneration,
|
|
2411
|
+
patches,
|
|
2412
|
+
unresolvedCount,
|
|
2413
|
+
excludePatterns
|
|
2414
|
+
};
|
|
2227
2415
|
}
|
|
2228
2416
|
export {
|
|
2229
2417
|
FERN_BOT_EMAIL,
|