@vedmalex/ai-connect 0.12.0 → 0.13.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.
@@ -1,5 +1,6 @@
1
1
  // src/registry/fs.ts
2
- import { readFile } from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
 
@@ -711,6 +712,59 @@ function parseRegistry(doc, _opts) {
711
712
  return { registry, issues };
712
713
  }
713
714
 
715
+ // src/registry/save.ts
716
+ function stripRegistrySecrets(registry) {
717
+ const strippedFields = [];
718
+ const endpoints = registry.endpoints.map((endpoint, index) => {
719
+ const auth = endpoint.auth;
720
+ if (!auth) {
721
+ return endpoint;
722
+ }
723
+ const hasToken = auth.token !== void 0;
724
+ const hasFallbackTokens = auth.fallbackTokens !== void 0;
725
+ if (!hasToken && !hasFallbackTokens) {
726
+ return endpoint;
727
+ }
728
+ const { token: _token, fallbackTokens: _fallbackTokens, ...restAuth } = auth;
729
+ if (hasToken) {
730
+ strippedFields.push(`endpoints[${index}].auth.token`);
731
+ }
732
+ if (hasFallbackTokens) {
733
+ strippedFields.push(`endpoints[${index}].auth.fallbackTokens`);
734
+ }
735
+ const nextEndpoint = { ...endpoint };
736
+ if (Object.keys(restAuth).length > 0) {
737
+ nextEndpoint.auth = restAuth;
738
+ } else {
739
+ delete nextEndpoint.auth;
740
+ }
741
+ return nextEndpoint;
742
+ });
743
+ return {
744
+ registry: { ...registry, endpoints },
745
+ strippedFields
746
+ };
747
+ }
748
+ function serializeRegistry(registry) {
749
+ const ordered = {};
750
+ if (registry.$schema !== void 0) {
751
+ ordered.$schema = registry.$schema;
752
+ }
753
+ ordered.version = registry.version;
754
+ if (registry.defaults !== void 0) {
755
+ ordered.defaults = registry.defaults;
756
+ }
757
+ ordered.endpoints = registry.endpoints;
758
+ if (registry.aliases !== void 0) {
759
+ ordered.aliases = registry.aliases;
760
+ }
761
+ return `${JSON.stringify(ordered, null, 2)}
762
+ `;
763
+ }
764
+ function mergeForSave(existing, incoming) {
765
+ return mergeRegistries([existing, incoming]).registry;
766
+ }
767
+
714
768
  // src/registry/fs.ts
715
769
  function isObject3(value) {
716
770
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -718,6 +772,9 @@ function isObject3(value) {
718
772
  function isEnoent(error) {
719
773
  return Boolean(error) && typeof error === "object" && error.code === "ENOENT";
720
774
  }
775
+ function isEexist(error) {
776
+ return Boolean(error) && typeof error === "object" && error.code === "EEXIST";
777
+ }
721
778
  function stripBom(text) {
722
779
  return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
723
780
  }
@@ -858,8 +915,280 @@ async function loadV1Pair(endpointsPath, aliasesPath) {
858
915
  const aliasesDoc = aliasesPath ? await readJsonFile(aliasesPath) : void 0;
859
916
  return convertV1Registry(endpointsDoc, aliasesDoc);
860
917
  }
918
+ function resolveSaveTarget(opts, filename) {
919
+ const env = opts.env ?? process.env;
920
+ const cwd = opts.cwd ?? process.cwd();
921
+ if (opts.path) {
922
+ return path.resolve(cwd, opts.path);
923
+ }
924
+ if (opts.layer === "user") {
925
+ return path.join(resolveXdgConfigHome(env), "ai-connect", filename);
926
+ }
927
+ if (opts.layer === "project") {
928
+ return path.join(cwd, ".config", "ai-connect", filename);
929
+ }
930
+ throw new AiConnectError(
931
+ "validation_error",
932
+ "Registry save target is ambiguous: supply either `layer` ('user' | 'project') or an explicit `path`."
933
+ );
934
+ }
935
+ async function readExistingJson(filePath) {
936
+ try {
937
+ return await readJsonFile(filePath);
938
+ } catch (error) {
939
+ if (error instanceof SyntaxError) {
940
+ throw new AiConnectError(
941
+ "validation_error",
942
+ `Existing file at "${filePath}" is not valid JSON (${error.message}); refusing to merge-save over it. The file was not modified.`,
943
+ { path: filePath }
944
+ );
945
+ }
946
+ throw error;
947
+ }
948
+ }
949
+ var BACKUP_MAX_ATTEMPTS = 100;
950
+ function escapeRegExpLiteral(value) {
951
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
952
+ }
953
+ async function createNumberedBackup(targetPath) {
954
+ const dir = path.dirname(targetPath);
955
+ const ext = path.extname(targetPath);
956
+ const stem = path.basename(targetPath, ext);
957
+ const pattern = new RegExp(`^${escapeRegExpLiteral(stem)}\\.(\\d+)${escapeRegExpLiteral(ext)}$`);
958
+ let entries;
959
+ try {
960
+ entries = await readdir(dir);
961
+ } catch (error) {
962
+ if (isEnoent(error)) {
963
+ entries = [];
964
+ } else {
965
+ throw error;
966
+ }
967
+ }
968
+ let maxIndex = -1;
969
+ for (const entry of entries) {
970
+ const match = pattern.exec(entry);
971
+ if (match) {
972
+ const parsed = Number.parseInt(match[1], 10);
973
+ if (parsed > maxIndex) {
974
+ maxIndex = parsed;
975
+ }
976
+ }
977
+ }
978
+ let nextIndex = maxIndex + 1;
979
+ for (let attempt = 0; attempt < BACKUP_MAX_ATTEMPTS; attempt++) {
980
+ const candidate = path.join(dir, `${stem}.${nextIndex}${ext}`);
981
+ try {
982
+ await copyFile(targetPath, candidate, fsConstants.COPYFILE_EXCL);
983
+ return candidate;
984
+ } catch (error) {
985
+ if (isEexist(error)) {
986
+ nextIndex += 1;
987
+ continue;
988
+ }
989
+ throw error;
990
+ }
991
+ }
992
+ throw new AiConnectError(
993
+ "validation_error",
994
+ `Could not create a numbered backup for "${targetPath}" after ${BACKUP_MAX_ATTEMPTS} index-collision retries.`,
995
+ { path: targetPath, attempts: BACKUP_MAX_ATTEMPTS }
996
+ );
997
+ }
998
+ async function atomicWriteWithBackup(targetPath, data, opts) {
999
+ const dir = path.dirname(targetPath);
1000
+ await mkdir(dir, { recursive: true });
1001
+ let existingText;
1002
+ try {
1003
+ existingText = await readFile(targetPath, "utf8");
1004
+ } catch (error) {
1005
+ if (!isEnoent(error)) {
1006
+ throw error;
1007
+ }
1008
+ }
1009
+ const targetExists = existingText !== void 0;
1010
+ if (targetExists && existingText === data) {
1011
+ return { created: false, wrote: false };
1012
+ }
1013
+ let backupPath;
1014
+ if (targetExists && opts.backup) {
1015
+ backupPath = await createNumberedBackup(targetPath);
1016
+ }
1017
+ const tmpPath = path.join(
1018
+ dir,
1019
+ `${path.basename(targetPath)}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
1020
+ );
1021
+ try {
1022
+ await writeFile(tmpPath, data, "utf8");
1023
+ await rename(tmpPath, targetPath);
1024
+ } catch (error) {
1025
+ await unlink(tmpPath).catch(() => {
1026
+ });
1027
+ throw error;
1028
+ }
1029
+ return {
1030
+ created: !targetExists,
1031
+ wrote: true,
1032
+ ...backupPath !== void 0 ? { backupPath } : {}
1033
+ };
1034
+ }
1035
+ function isNonEmptyStringId(value) {
1036
+ return typeof value === "string" && value.trim().length > 0;
1037
+ }
1038
+ function buildRawExistingRegistry(doc, targetPath) {
1039
+ if (!isObject3(doc)) {
1040
+ throw new AiConnectError(
1041
+ "validation_error",
1042
+ `Existing file at "${targetPath}" is not a JSON object; refusing to merge-save over it. The file was not modified.`,
1043
+ { path: targetPath }
1044
+ );
1045
+ }
1046
+ const rawEndpoints = Array.isArray(doc.endpoints) ? doc.endpoints : [];
1047
+ rawEndpoints.forEach((entry, index) => {
1048
+ if (!isObject3(entry) || !isNonEmptyStringId(entry.id)) {
1049
+ throw new AiConnectError(
1050
+ "validation_error",
1051
+ `Existing file at "${targetPath}" has an invalid entry at endpoints[${index}] (must be an object with a non-empty string id to be merge-keyed); refusing to merge-save over it. The file was not modified.`,
1052
+ { path: targetPath, index, field: "endpoints" }
1053
+ );
1054
+ }
1055
+ });
1056
+ const rawAliases = Array.isArray(doc.aliases) ? doc.aliases : [];
1057
+ rawAliases.forEach((entry, index) => {
1058
+ if (!isObject3(entry) || !isNonEmptyStringId(entry.id)) {
1059
+ throw new AiConnectError(
1060
+ "validation_error",
1061
+ `Existing file at "${targetPath}" has an invalid entry at aliases[${index}] (must be an object with a non-empty string id to be merge-keyed); refusing to merge-save over it. The file was not modified.`,
1062
+ { path: targetPath, index, field: "aliases" }
1063
+ );
1064
+ }
1065
+ });
1066
+ return {
1067
+ version: 2,
1068
+ ...typeof doc.$schema === "string" ? { $schema: doc.$schema } : {},
1069
+ ...isObject3(doc.defaults) ? { defaults: doc.defaults } : {},
1070
+ endpoints: rawEndpoints,
1071
+ ...rawAliases.length > 0 ? { aliases: rawAliases } : {}
1072
+ };
1073
+ }
1074
+ async function saveConnectionRegistry(registry, opts = {}) {
1075
+ const env = opts.env ?? process.env;
1076
+ const cwd = opts.cwd ?? process.cwd();
1077
+ const mode = opts.mode ?? "merge";
1078
+ const backup = opts.backup ?? true;
1079
+ const targetPath = resolveSaveTarget({ layer: opts.layer, path: opts.path, cwd, env }, "connections.json");
1080
+ let finalRegistry = registry;
1081
+ if (mode === "merge") {
1082
+ const existingDoc = await readExistingJson(targetPath);
1083
+ if (existingDoc !== void 0) {
1084
+ const detected = detectRegistryVersion(existingDoc);
1085
+ let existingRegistry;
1086
+ if (detected === "v2") {
1087
+ existingRegistry = buildRawExistingRegistry(existingDoc, targetPath);
1088
+ } else if (detected === "v1-endpoints") {
1089
+ const aliasesPath = path.join(path.dirname(targetPath), "rag-aliases.json");
1090
+ const aliasesDoc = await readExistingJson(aliasesPath);
1091
+ existingRegistry = convertV1Registry(existingDoc, aliasesDoc).registry;
1092
+ } else {
1093
+ throw new AiConnectError(
1094
+ "validation_error",
1095
+ `Existing file at "${targetPath}" is not a recognized v2 document or v1-endpoints file (detected: ${detected}); refusing to merge-save over it. The file was not modified.`,
1096
+ { path: targetPath, detected }
1097
+ );
1098
+ }
1099
+ finalRegistry = mergeForSave(existingRegistry, registry);
1100
+ }
1101
+ }
1102
+ const stripped = stripRegistrySecrets(finalRegistry);
1103
+ const data = serializeRegistry(stripped.registry);
1104
+ const writeResult = await atomicWriteWithBackup(targetPath, data, { backup });
1105
+ return {
1106
+ path: targetPath,
1107
+ mode,
1108
+ created: writeResult.created,
1109
+ ...writeResult.backupPath !== void 0 ? { backupPath: writeResult.backupPath } : {},
1110
+ strippedFields: stripped.strippedFields
1111
+ };
1112
+ }
1113
+ function fragmentToPseudoRegistry(fragment) {
1114
+ return {
1115
+ version: 2,
1116
+ endpoints: fragment.endpoints ?? [],
1117
+ ...fragment.aliases !== void 0 ? { aliases: fragment.aliases } : {},
1118
+ ...fragment.defaults !== void 0 ? { defaults: fragment.defaults } : {}
1119
+ };
1120
+ }
1121
+ function mergeSecretsFragments(existing, incoming) {
1122
+ const merged = mergeRegistries([
1123
+ fragmentToPseudoRegistry(existing),
1124
+ fragmentToPseudoRegistry(incoming)
1125
+ ]).registry;
1126
+ return {
1127
+ ...merged.endpoints.length > 0 ? { endpoints: merged.endpoints } : {},
1128
+ ...merged.aliases !== void 0 ? { aliases: merged.aliases } : {},
1129
+ ...merged.defaults !== void 0 ? { defaults: merged.defaults } : {}
1130
+ };
1131
+ }
1132
+ function serializeSecretsFragment(fragment) {
1133
+ const ordered = {};
1134
+ if (fragment.endpoints !== void 0) {
1135
+ ordered.endpoints = fragment.endpoints;
1136
+ }
1137
+ if (fragment.aliases !== void 0) {
1138
+ ordered.aliases = fragment.aliases;
1139
+ }
1140
+ if (fragment.defaults !== void 0) {
1141
+ ordered.defaults = fragment.defaults;
1142
+ }
1143
+ return `${JSON.stringify(ordered, null, 2)}
1144
+ `;
1145
+ }
1146
+ function readRawSecretsFragment(doc) {
1147
+ if (!isObject3(doc)) {
1148
+ return void 0;
1149
+ }
1150
+ const fragment = {};
1151
+ if (Array.isArray(doc.endpoints)) {
1152
+ fragment.endpoints = doc.endpoints;
1153
+ }
1154
+ if (Array.isArray(doc.aliases)) {
1155
+ fragment.aliases = doc.aliases;
1156
+ }
1157
+ if (isObject3(doc.defaults)) {
1158
+ fragment.defaults = doc.defaults;
1159
+ }
1160
+ return fragment;
1161
+ }
1162
+ async function saveConnectionSecrets(fragment, opts = {}) {
1163
+ const env = opts.env ?? process.env;
1164
+ const cwd = opts.cwd ?? process.cwd();
1165
+ const mode = opts.mode ?? "merge";
1166
+ const backup = opts.backup ?? true;
1167
+ const targetPath = resolveSaveTarget(
1168
+ { layer: opts.layer, path: opts.path, cwd, env },
1169
+ "connections.secrets.json"
1170
+ );
1171
+ let finalFragment = fragment;
1172
+ if (mode === "merge") {
1173
+ const existingDoc = await readExistingJson(targetPath);
1174
+ const existingFragment = readRawSecretsFragment(existingDoc);
1175
+ if (existingFragment) {
1176
+ finalFragment = mergeSecretsFragments(existingFragment, fragment);
1177
+ }
1178
+ }
1179
+ const data = serializeSecretsFragment(finalFragment);
1180
+ const writeResult = await atomicWriteWithBackup(targetPath, data, { backup });
1181
+ return {
1182
+ path: targetPath,
1183
+ mode,
1184
+ created: writeResult.created,
1185
+ ...writeResult.backupPath !== void 0 ? { backupPath: writeResult.backupPath } : {}
1186
+ };
1187
+ }
861
1188
  export {
862
1189
  loadConnectionRegistry,
863
- loadV1Pair
1190
+ loadV1Pair,
1191
+ saveConnectionRegistry,
1192
+ saveConnectionSecrets
864
1193
  };
865
1194
  //# sourceMappingURL=registry-fs.js.map