@njdamstra/appwrite-utils-cli 1.11.5 → 1.11.7
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/migrations/migrateStrings.js +181 -16
- package/package.json +1 -1
- package/src/migrations/migrateStrings.ts +218 -23
|
@@ -275,6 +275,27 @@ export async function executeMigrationPlan(adapter, options) {
|
|
|
275
275
|
MessageFormatter.error(` ${entry.attributeKey}: FAILED — ${cpEntry.error}`, undefined, { prefix: "Execute" });
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
+
// Restore required flags after all attributes in this collection are done.
|
|
279
|
+
// This must happen AFTER all migrations to avoid partial-update validation
|
|
280
|
+
// errors (Appwrite rejects updateRow if a required attribute is missing
|
|
281
|
+
// from the payload, even for partial updates).
|
|
282
|
+
const completedRequired = entries.filter((e) => {
|
|
283
|
+
const cp = findCheckpointEntry(checkpoint, e);
|
|
284
|
+
return cp?.phase === "completed" && e.isRequired;
|
|
285
|
+
});
|
|
286
|
+
for (const entry of completedRequired) {
|
|
287
|
+
try {
|
|
288
|
+
await tryAwaitWithRetry(() => adapter.updateAttribute({
|
|
289
|
+
databaseId: entry.databaseId,
|
|
290
|
+
tableId: entry.collectionId,
|
|
291
|
+
key: entry.attributeKey,
|
|
292
|
+
required: true,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
MessageFormatter.info(` Warning: could not set ${entry.attributeKey} back to required`, { prefix: "Execute" });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
278
299
|
// After collection completes, offer to update local YAML
|
|
279
300
|
const successInGroup = entries.filter((e) => {
|
|
280
301
|
const cp = findCheckpointEntry(checkpoint, e);
|
|
@@ -290,7 +311,7 @@ export async function executeMigrationPlan(adapter, options) {
|
|
|
290
311
|
},
|
|
291
312
|
]);
|
|
292
313
|
if (updateYaml) {
|
|
293
|
-
|
|
314
|
+
await updateCollectionYaml(first.collectionName, entries, checkpoint);
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
}
|
|
@@ -426,21 +447,8 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
|
|
|
426
447
|
advance("backup_deleted");
|
|
427
448
|
}
|
|
428
449
|
// Step 9: Mark completed
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
try {
|
|
432
|
-
await tryAwaitWithRetry(() => adapter.updateAttribute({
|
|
433
|
-
databaseId,
|
|
434
|
-
tableId: collectionId,
|
|
435
|
-
key: attributeKey,
|
|
436
|
-
required: true,
|
|
437
|
-
}));
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
// Non-fatal — attribute is migrated, just not set back to required
|
|
441
|
-
MessageFormatter.info(` Warning: could not set ${attributeKey} back to required`, { prefix: "Migrate" });
|
|
442
|
-
}
|
|
443
|
-
}
|
|
450
|
+
// NOTE: required flag is restored AFTER all attributes in the collection
|
|
451
|
+
// are migrated, to avoid partial-update validation errors on other attributes.
|
|
444
452
|
advance("completed");
|
|
445
453
|
}
|
|
446
454
|
// ────────────────────────────────────────────────────────
|
|
@@ -724,6 +732,163 @@ function printDryRunSummary(plan) {
|
|
|
724
732
|
console.log("");
|
|
725
733
|
}
|
|
726
734
|
// ────────────────────────────────────────────────────────
|
|
735
|
+
// Update local collection YAML after migration
|
|
736
|
+
// ────────────────────────────────────────────────────────
|
|
737
|
+
async function updateCollectionYaml(collectionName, entries, checkpoint) {
|
|
738
|
+
// Find candidate YAML files
|
|
739
|
+
const candidates = findYamlFiles(process.cwd(), collectionName);
|
|
740
|
+
if (candidates.length === 0) {
|
|
741
|
+
MessageFormatter.warning(`No YAML file found for collection "${collectionName}". Skipping local config update.`, { prefix: "YAML" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
let yamlPath;
|
|
745
|
+
if (candidates.length === 1) {
|
|
746
|
+
const { usePath } = await inquirer.prompt([
|
|
747
|
+
{
|
|
748
|
+
type: "confirm",
|
|
749
|
+
name: "usePath",
|
|
750
|
+
message: `Found: ${candidates[0]}. Use this file?`,
|
|
751
|
+
default: true,
|
|
752
|
+
},
|
|
753
|
+
]);
|
|
754
|
+
if (!usePath) {
|
|
755
|
+
const { customPath } = await inquirer.prompt([
|
|
756
|
+
{
|
|
757
|
+
type: "input",
|
|
758
|
+
name: "customPath",
|
|
759
|
+
message: "Enter path to collection YAML file:",
|
|
760
|
+
},
|
|
761
|
+
]);
|
|
762
|
+
if (!customPath || !fs.existsSync(customPath)) {
|
|
763
|
+
MessageFormatter.warning("Invalid path. Skipping YAML update.", {
|
|
764
|
+
prefix: "YAML",
|
|
765
|
+
});
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
yamlPath = customPath;
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
yamlPath = candidates[0];
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
const { selectedPath } = await inquirer.prompt([
|
|
776
|
+
{
|
|
777
|
+
type: "list",
|
|
778
|
+
name: "selectedPath",
|
|
779
|
+
message: `Multiple YAML files found for "${collectionName}". Select one:`,
|
|
780
|
+
choices: [
|
|
781
|
+
...candidates.map((c) => ({ name: c, value: c })),
|
|
782
|
+
{ name: "Enter custom path", value: "__custom__" },
|
|
783
|
+
],
|
|
784
|
+
},
|
|
785
|
+
]);
|
|
786
|
+
if (selectedPath === "__custom__") {
|
|
787
|
+
const { customPath } = await inquirer.prompt([
|
|
788
|
+
{
|
|
789
|
+
type: "input",
|
|
790
|
+
name: "customPath",
|
|
791
|
+
message: "Enter path to collection YAML file:",
|
|
792
|
+
},
|
|
793
|
+
]);
|
|
794
|
+
if (!customPath || !fs.existsSync(customPath)) {
|
|
795
|
+
MessageFormatter.warning("Invalid path. Skipping YAML update.", {
|
|
796
|
+
prefix: "YAML",
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
yamlPath = customPath;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
yamlPath = selectedPath;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Load and parse YAML
|
|
807
|
+
let doc;
|
|
808
|
+
try {
|
|
809
|
+
const content = fs.readFileSync(yamlPath, "utf8");
|
|
810
|
+
doc = yaml.load(content);
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
MessageFormatter.error(`Failed to parse ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!doc || !Array.isArray(doc.attributes)) {
|
|
817
|
+
MessageFormatter.warning(`No "attributes" array found in ${yamlPath}. Skipping.`, { prefix: "YAML" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// Update attributes that were successfully migrated
|
|
821
|
+
let updated = 0;
|
|
822
|
+
for (const entry of entries) {
|
|
823
|
+
const cp = findCheckpointEntry(checkpoint, entry);
|
|
824
|
+
if (cp?.phase !== "completed")
|
|
825
|
+
continue;
|
|
826
|
+
const attr = doc.attributes.find((a) => a.key === entry.attributeKey && a.type === "string");
|
|
827
|
+
if (!attr)
|
|
828
|
+
continue;
|
|
829
|
+
attr.type = entry.targetType;
|
|
830
|
+
if (entry.targetType !== "varchar") {
|
|
831
|
+
delete attr.size;
|
|
832
|
+
}
|
|
833
|
+
updated++;
|
|
834
|
+
}
|
|
835
|
+
if (updated === 0) {
|
|
836
|
+
MessageFormatter.info("No matching string attributes found in YAML to update.", { prefix: "YAML" });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
// Write back
|
|
840
|
+
try {
|
|
841
|
+
const output = yaml.dump(doc, {
|
|
842
|
+
lineWidth: 120,
|
|
843
|
+
noRefs: true,
|
|
844
|
+
sortKeys: false,
|
|
845
|
+
});
|
|
846
|
+
fs.writeFileSync(yamlPath, output, "utf8");
|
|
847
|
+
MessageFormatter.success(`Updated ${updated} attribute(s) in ${yamlPath}`, { prefix: "YAML" });
|
|
848
|
+
}
|
|
849
|
+
catch (err) {
|
|
850
|
+
MessageFormatter.error(`Failed to write ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
function findYamlFiles(searchRoot, collectionName) {
|
|
854
|
+
const results = [];
|
|
855
|
+
const lowerName = collectionName.toLowerCase();
|
|
856
|
+
function walk(dir) {
|
|
857
|
+
let dirEntries;
|
|
858
|
+
try {
|
|
859
|
+
dirEntries = fs.readdirSync(dir, { withFileTypes: true });
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return; // skip inaccessible directories
|
|
863
|
+
}
|
|
864
|
+
for (const ent of dirEntries) {
|
|
865
|
+
const fullPath = path.join(dir, ent.name);
|
|
866
|
+
if (ent.isDirectory()) {
|
|
867
|
+
// Skip node_modules, .git, and other common non-config dirs
|
|
868
|
+
if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist")
|
|
869
|
+
continue;
|
|
870
|
+
walk(fullPath);
|
|
871
|
+
}
|
|
872
|
+
else if (ent.isFile() &&
|
|
873
|
+
ent.name.toLowerCase() === `${lowerName}.yaml`) {
|
|
874
|
+
// Verify it looks like a collection config (has a name field)
|
|
875
|
+
try {
|
|
876
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
877
|
+
const parsed = yaml.load(content);
|
|
878
|
+
if (parsed && parsed.name === collectionName) {
|
|
879
|
+
results.push(fullPath);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// skip unparseable files
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
walk(searchRoot);
|
|
889
|
+
return results;
|
|
890
|
+
}
|
|
891
|
+
// ────────────────────────────────────────────────────────
|
|
727
892
|
// Utility
|
|
728
893
|
// ────────────────────────────────────────────────────────
|
|
729
894
|
async function createAttributeIfNotExists(adapter, params) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@njdamstra/appwrite-utils-cli",
|
|
3
3
|
"description": "Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.",
|
|
4
|
-
"version": "1.11.
|
|
4
|
+
"version": "1.11.7",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -382,6 +382,32 @@ export async function executeMigrationPlan(
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Restore required flags after all attributes in this collection are done.
|
|
386
|
+
// This must happen AFTER all migrations to avoid partial-update validation
|
|
387
|
+
// errors (Appwrite rejects updateRow if a required attribute is missing
|
|
388
|
+
// from the payload, even for partial updates).
|
|
389
|
+
const completedRequired = entries.filter((e) => {
|
|
390
|
+
const cp = findCheckpointEntry(checkpoint, e);
|
|
391
|
+
return cp?.phase === "completed" && e.isRequired;
|
|
392
|
+
});
|
|
393
|
+
for (const entry of completedRequired) {
|
|
394
|
+
try {
|
|
395
|
+
await tryAwaitWithRetry(() =>
|
|
396
|
+
adapter.updateAttribute({
|
|
397
|
+
databaseId: entry.databaseId,
|
|
398
|
+
tableId: entry.collectionId,
|
|
399
|
+
key: entry.attributeKey,
|
|
400
|
+
required: true,
|
|
401
|
+
} as any)
|
|
402
|
+
);
|
|
403
|
+
} catch {
|
|
404
|
+
MessageFormatter.info(
|
|
405
|
+
` Warning: could not set ${entry.attributeKey} back to required`,
|
|
406
|
+
{ prefix: "Execute" }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
385
411
|
// After collection completes, offer to update local YAML
|
|
386
412
|
const successInGroup = entries.filter((e) => {
|
|
387
413
|
const cp = findCheckpointEntry(checkpoint, e);
|
|
@@ -398,10 +424,7 @@ export async function executeMigrationPlan(
|
|
|
398
424
|
},
|
|
399
425
|
]);
|
|
400
426
|
if (updateYaml) {
|
|
401
|
-
|
|
402
|
-
"Local YAML update: use your editor to change 'type: string' to the new types in your collection YAML files.",
|
|
403
|
-
{ prefix: "Execute" }
|
|
404
|
-
);
|
|
427
|
+
await updateCollectionYaml(first.collectionName, entries, checkpoint);
|
|
405
428
|
}
|
|
406
429
|
}
|
|
407
430
|
}
|
|
@@ -633,25 +656,8 @@ async function migrateOneAttribute(
|
|
|
633
656
|
}
|
|
634
657
|
|
|
635
658
|
// Step 9: Mark completed
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
try {
|
|
639
|
-
await tryAwaitWithRetry(() =>
|
|
640
|
-
adapter.updateAttribute({
|
|
641
|
-
databaseId,
|
|
642
|
-
tableId: collectionId,
|
|
643
|
-
key: attributeKey,
|
|
644
|
-
required: true,
|
|
645
|
-
} as any)
|
|
646
|
-
);
|
|
647
|
-
} catch {
|
|
648
|
-
// Non-fatal — attribute is migrated, just not set back to required
|
|
649
|
-
MessageFormatter.info(
|
|
650
|
-
` Warning: could not set ${attributeKey} back to required`,
|
|
651
|
-
{ prefix: "Migrate" }
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
659
|
+
// NOTE: required flag is restored AFTER all attributes in the collection
|
|
660
|
+
// are migrated, to avoid partial-update validation errors on other attributes.
|
|
655
661
|
advance("completed");
|
|
656
662
|
}
|
|
657
663
|
|
|
@@ -1075,6 +1081,195 @@ function printDryRunSummary(plan: MigrationPlan): void {
|
|
|
1075
1081
|
console.log("");
|
|
1076
1082
|
}
|
|
1077
1083
|
|
|
1084
|
+
// ────────────────────────────────────────────────────────
|
|
1085
|
+
// Update local collection YAML after migration
|
|
1086
|
+
// ────────────────────────────────────────────────────────
|
|
1087
|
+
|
|
1088
|
+
async function updateCollectionYaml(
|
|
1089
|
+
collectionName: string,
|
|
1090
|
+
entries: MigrationPlanEntry[],
|
|
1091
|
+
checkpoint: MigrationCheckpoint
|
|
1092
|
+
): Promise<void> {
|
|
1093
|
+
// Find candidate YAML files
|
|
1094
|
+
const candidates = findYamlFiles(process.cwd(), collectionName);
|
|
1095
|
+
|
|
1096
|
+
if (candidates.length === 0) {
|
|
1097
|
+
MessageFormatter.warning(
|
|
1098
|
+
`No YAML file found for collection "${collectionName}". Skipping local config update.`,
|
|
1099
|
+
{ prefix: "YAML" }
|
|
1100
|
+
);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
let yamlPath: string;
|
|
1105
|
+
|
|
1106
|
+
if (candidates.length === 1) {
|
|
1107
|
+
const { usePath } = await inquirer.prompt([
|
|
1108
|
+
{
|
|
1109
|
+
type: "confirm",
|
|
1110
|
+
name: "usePath",
|
|
1111
|
+
message: `Found: ${candidates[0]}. Use this file?`,
|
|
1112
|
+
default: true,
|
|
1113
|
+
},
|
|
1114
|
+
]);
|
|
1115
|
+
if (!usePath) {
|
|
1116
|
+
const { customPath } = await inquirer.prompt([
|
|
1117
|
+
{
|
|
1118
|
+
type: "input",
|
|
1119
|
+
name: "customPath",
|
|
1120
|
+
message: "Enter path to collection YAML file:",
|
|
1121
|
+
},
|
|
1122
|
+
]);
|
|
1123
|
+
if (!customPath || !fs.existsSync(customPath)) {
|
|
1124
|
+
MessageFormatter.warning("Invalid path. Skipping YAML update.", {
|
|
1125
|
+
prefix: "YAML",
|
|
1126
|
+
});
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
yamlPath = customPath;
|
|
1130
|
+
} else {
|
|
1131
|
+
yamlPath = candidates[0];
|
|
1132
|
+
}
|
|
1133
|
+
} else {
|
|
1134
|
+
const { selectedPath } = await inquirer.prompt([
|
|
1135
|
+
{
|
|
1136
|
+
type: "list",
|
|
1137
|
+
name: "selectedPath",
|
|
1138
|
+
message: `Multiple YAML files found for "${collectionName}". Select one:`,
|
|
1139
|
+
choices: [
|
|
1140
|
+
...candidates.map((c) => ({ name: c, value: c })),
|
|
1141
|
+
{ name: "Enter custom path", value: "__custom__" },
|
|
1142
|
+
],
|
|
1143
|
+
},
|
|
1144
|
+
]);
|
|
1145
|
+
if (selectedPath === "__custom__") {
|
|
1146
|
+
const { customPath } = await inquirer.prompt([
|
|
1147
|
+
{
|
|
1148
|
+
type: "input",
|
|
1149
|
+
name: "customPath",
|
|
1150
|
+
message: "Enter path to collection YAML file:",
|
|
1151
|
+
},
|
|
1152
|
+
]);
|
|
1153
|
+
if (!customPath || !fs.existsSync(customPath)) {
|
|
1154
|
+
MessageFormatter.warning("Invalid path. Skipping YAML update.", {
|
|
1155
|
+
prefix: "YAML",
|
|
1156
|
+
});
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
yamlPath = customPath;
|
|
1160
|
+
} else {
|
|
1161
|
+
yamlPath = selectedPath;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Load and parse YAML
|
|
1166
|
+
let doc: any;
|
|
1167
|
+
try {
|
|
1168
|
+
const content = fs.readFileSync(yamlPath, "utf8");
|
|
1169
|
+
doc = yaml.load(content);
|
|
1170
|
+
} catch (err: any) {
|
|
1171
|
+
MessageFormatter.error(
|
|
1172
|
+
`Failed to parse ${yamlPath}: ${err.message}`,
|
|
1173
|
+
undefined,
|
|
1174
|
+
{ prefix: "YAML" }
|
|
1175
|
+
);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (!doc || !Array.isArray(doc.attributes)) {
|
|
1180
|
+
MessageFormatter.warning(
|
|
1181
|
+
`No "attributes" array found in ${yamlPath}. Skipping.`,
|
|
1182
|
+
{ prefix: "YAML" }
|
|
1183
|
+
);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Update attributes that were successfully migrated
|
|
1188
|
+
let updated = 0;
|
|
1189
|
+
for (const entry of entries) {
|
|
1190
|
+
const cp = findCheckpointEntry(checkpoint, entry);
|
|
1191
|
+
if (cp?.phase !== "completed") continue;
|
|
1192
|
+
|
|
1193
|
+
const attr = doc.attributes.find(
|
|
1194
|
+
(a: any) => a.key === entry.attributeKey && a.type === "string"
|
|
1195
|
+
);
|
|
1196
|
+
if (!attr) continue;
|
|
1197
|
+
|
|
1198
|
+
attr.type = entry.targetType;
|
|
1199
|
+
if (entry.targetType !== "varchar") {
|
|
1200
|
+
delete attr.size;
|
|
1201
|
+
}
|
|
1202
|
+
updated++;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (updated === 0) {
|
|
1206
|
+
MessageFormatter.info(
|
|
1207
|
+
"No matching string attributes found in YAML to update.",
|
|
1208
|
+
{ prefix: "YAML" }
|
|
1209
|
+
);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Write back
|
|
1214
|
+
try {
|
|
1215
|
+
const output = yaml.dump(doc, {
|
|
1216
|
+
lineWidth: 120,
|
|
1217
|
+
noRefs: true,
|
|
1218
|
+
sortKeys: false,
|
|
1219
|
+
});
|
|
1220
|
+
fs.writeFileSync(yamlPath, output, "utf8");
|
|
1221
|
+
MessageFormatter.success(
|
|
1222
|
+
`Updated ${updated} attribute(s) in ${yamlPath}`,
|
|
1223
|
+
{ prefix: "YAML" }
|
|
1224
|
+
);
|
|
1225
|
+
} catch (err: any) {
|
|
1226
|
+
MessageFormatter.error(
|
|
1227
|
+
`Failed to write ${yamlPath}: ${err.message}`,
|
|
1228
|
+
undefined,
|
|
1229
|
+
{ prefix: "YAML" }
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function findYamlFiles(searchRoot: string, collectionName: string): string[] {
|
|
1235
|
+
const results: string[] = [];
|
|
1236
|
+
const lowerName = collectionName.toLowerCase();
|
|
1237
|
+
|
|
1238
|
+
function walk(dir: string): void {
|
|
1239
|
+
let dirEntries: fs.Dirent[];
|
|
1240
|
+
try {
|
|
1241
|
+
dirEntries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1242
|
+
} catch {
|
|
1243
|
+
return; // skip inaccessible directories
|
|
1244
|
+
}
|
|
1245
|
+
for (const ent of dirEntries) {
|
|
1246
|
+
const fullPath = path.join(dir, ent.name);
|
|
1247
|
+
if (ent.isDirectory()) {
|
|
1248
|
+
// Skip node_modules, .git, and other common non-config dirs
|
|
1249
|
+
if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist") continue;
|
|
1250
|
+
walk(fullPath);
|
|
1251
|
+
} else if (
|
|
1252
|
+
ent.isFile() &&
|
|
1253
|
+
ent.name.toLowerCase() === `${lowerName}.yaml`
|
|
1254
|
+
) {
|
|
1255
|
+
// Verify it looks like a collection config (has a name field)
|
|
1256
|
+
try {
|
|
1257
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
1258
|
+
const parsed = yaml.load(content) as any;
|
|
1259
|
+
if (parsed && parsed.name === collectionName) {
|
|
1260
|
+
results.push(fullPath);
|
|
1261
|
+
}
|
|
1262
|
+
} catch {
|
|
1263
|
+
// skip unparseable files
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
walk(searchRoot);
|
|
1270
|
+
return results;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1078
1273
|
// ────────────────────────────────────────────────────────
|
|
1079
1274
|
// Utility
|
|
1080
1275
|
// ────────────────────────────────────────────────────────
|