@eide/foir-cli 0.4.14 → 0.5.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 (2) hide show
  1. package/dist/cli.js +164 -106
  2. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -5112,16 +5112,13 @@ async function reconcileApiKeys(client, apiKeys, summary) {
5112
5112
  }
5113
5113
 
5114
5114
  // src/lib/validate-integrations.ts
5115
- var KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
5116
- var KNOWN_CREDENTIAL_STRATEGIES = /* @__PURE__ */ new Set([
5117
- "oauth",
5118
- "api_key",
5119
- "shared_secret",
5120
- "ssh_key",
5121
- "none",
5122
- "managed"
5123
- ]);
5124
- var KNOWN_EXTENSION_TARGETS = /* @__PURE__ */ new Set(["record", "model-list"]);
5115
+ import { fromJson as fromJson3 } from "@bufbuild/protobuf";
5116
+ import { pathToString } from "@bufbuild/protobuf/reflect";
5117
+ import { createValidator } from "@bufbuild/protovalidate";
5118
+ import {
5119
+ IntegrationConfigSchema,
5120
+ ExtensionConfigSchema
5121
+ } from "@eide/foir-proto-ts/integrations/v1/integrations_pb";
5125
5122
  var IntegrationValidationError = class extends Error {
5126
5123
  errors;
5127
5124
  constructor(errors) {
@@ -5133,6 +5130,7 @@ ${errors.map((e) => ` - ${e}`).join("\n")}`
5133
5130
  this.errors = errors;
5134
5131
  }
5135
5132
  };
5133
+ var validator = createValidator();
5136
5134
  function validateIntegrationsAndExtensions(manifest) {
5137
5135
  const errors = [];
5138
5136
  const warnings = [];
@@ -5159,29 +5157,14 @@ function assertValid(result) {
5159
5157
  }
5160
5158
  function validateIntegration(name, integration, modelsByKey, errors, warnings) {
5161
5159
  const prefix = `integration '${name}'`;
5162
- if (!KEBAB_CASE.test(name)) {
5163
- errors.push(`${prefix}: name must be kebab-case (lowercase, dash-separated)`);
5164
- }
5165
- const middlewareUrl = integration.middleware?.url;
5166
- if (!middlewareUrl || typeof middlewareUrl !== "string") {
5167
- errors.push(`${prefix}: middleware.url is required`);
5168
- } else if (!isWellFormedHttpsUrl(middlewareUrl)) {
5169
- errors.push(
5170
- `${prefix}: middleware.url must be a well-formed https URL, got '${middlewareUrl}'`
5171
- );
5172
- }
5173
- const strategy = integration.credentials?.strategy;
5174
- if (!strategy) {
5175
- errors.push(`${prefix}: credentials.strategy is required`);
5176
- } else if (!KNOWN_CREDENTIAL_STRATEGIES.has(strategy)) {
5177
- errors.push(
5178
- `${prefix}: credentials.strategy '${strategy}' is not one of ${[...KNOWN_CREDENTIAL_STRATEGIES].join(", ")}`
5179
- );
5180
- }
5160
+ const protoJson = toIntegrationProtoJson(name, integration);
5161
+ const structuralErrors = runProtoValidation(
5162
+ prefix,
5163
+ protoJson,
5164
+ (json) => fromJson3(IntegrationConfigSchema, json, { ignoreUnknownFields: true })
5165
+ );
5166
+ errors.push(...structuralErrors);
5181
5167
  const sync = integration.sync ?? {};
5182
- if (Object.keys(sync).length === 0) {
5183
- errors.push(`${prefix}: sync must declare at least one source type`);
5184
- }
5185
5168
  for (const [sourceType, mapping] of Object.entries(sync)) {
5186
5169
  validateSyncMapping(
5187
5170
  `${prefix} sync.${sourceType}`,
@@ -5194,12 +5177,11 @@ function validateIntegration(name, integration, modelsByKey, errors, warnings) {
5194
5177
  }
5195
5178
  }
5196
5179
  function validateSyncMapping(prefix, mapping, integrationName, modelsByKey, errors, warnings) {
5197
- if (!mapping.model || typeof mapping.model !== "string") {
5198
- errors.push(`${prefix}: model is required`);
5180
+ const model = mapping.model ? modelsByKey.get(mapping.model) : void 0;
5181
+ const seed = mapping.modelSeed;
5182
+ if (!mapping.model) {
5199
5183
  return;
5200
5184
  }
5201
- const model = modelsByKey.get(mapping.model);
5202
- const seed = mapping.modelSeed;
5203
5185
  if (!model && !seed) {
5204
5186
  errors.push(
5205
5187
  `${prefix}: missing model '${mapping.model}' referenced by integration '${integrationName}' \u2014 declare it under models or provide a modelSeed`
@@ -5207,52 +5189,22 @@ function validateSyncMapping(prefix, mapping, integrationName, modelsByKey, erro
5207
5189
  return;
5208
5190
  }
5209
5191
  const fieldKeys = model ? new Set((model.fields ?? []).map((f) => f.key)) : new Set(Object.keys(seed?.fields ?? {}));
5210
- const naturalKeyField = mapping.naturalKey;
5211
- if (!naturalKeyField || typeof naturalKeyField !== "string") {
5212
- errors.push(`${prefix}: naturalKey is required`);
5213
- } else if (!fieldKeys.has(naturalKeyField)) {
5192
+ if (mapping.naturalKey && !fieldKeys.has(mapping.naturalKey)) {
5214
5193
  errors.push(
5215
- `${prefix}: naturalKey '${naturalKeyField}' does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5194
+ `${prefix}: naturalKey '${mapping.naturalKey}' does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5216
5195
  );
5217
5196
  }
5218
5197
  const fields = mapping.fields ?? {};
5219
- if (Object.keys(fields).length === 0) {
5220
- errors.push(`${prefix}: fields mapping must declare at least one field`);
5221
- }
5222
5198
  for (const [sourceField, foirField] of Object.entries(fields)) {
5223
- if (!foirField || typeof foirField !== "string") {
5224
- errors.push(
5225
- `${prefix}: field mapping for source '${sourceField}' must be a non-empty string`
5226
- );
5227
- continue;
5228
- }
5199
+ if (!foirField) continue;
5229
5200
  if (!fieldKeys.has(foirField)) {
5230
5201
  errors.push(
5231
5202
  `${prefix}: field mapping '${sourceField} -> ${foirField}' references field '${foirField}' which does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5232
5203
  );
5233
5204
  }
5234
5205
  }
5235
- if (seed) {
5236
- validateSeedStructure(prefix, seed, errors);
5237
- if (model) {
5238
- detectSeedDrift(prefix, seed, model, warnings);
5239
- }
5240
- }
5241
- }
5242
- function validateSeedStructure(prefix, seed, errors) {
5243
- const fields = seed.fields ?? {};
5244
- if (Object.keys(fields).length === 0) {
5245
- errors.push(`${prefix}: modelSeed.fields must declare at least one field`);
5246
- return;
5247
- }
5248
- for (const [key, field] of Object.entries(fields)) {
5249
- if (!field || typeof field !== "object") {
5250
- errors.push(`${prefix}: modelSeed.fields.${key} must be an object`);
5251
- continue;
5252
- }
5253
- if (!field.type || typeof field.type !== "string") {
5254
- errors.push(`${prefix}: modelSeed.fields.${key}.type is required`);
5255
- }
5206
+ if (seed && model) {
5207
+ detectSeedDrift(prefix, seed, model, warnings);
5256
5208
  }
5257
5209
  }
5258
5210
  function detectSeedDrift(prefix, seed, model, warnings) {
@@ -5275,50 +5227,156 @@ function detectSeedDrift(prefix, seed, model, warnings) {
5275
5227
  }
5276
5228
  function validateExtension(name, extension, modelsByKey, errors) {
5277
5229
  const prefix = `extension '${name}'`;
5278
- if (!KEBAB_CASE.test(name)) {
5279
- errors.push(`${prefix}: name must be kebab-case (lowercase, dash-separated)`);
5280
- }
5281
- if (!extension.url || typeof extension.url !== "string") {
5282
- errors.push(`${prefix}: url is required`);
5283
- } else if (!isWellFormedHttpsUrl(extension.url)) {
5284
- errors.push(
5285
- `${prefix}: url must be a well-formed https URL, got '${extension.url}'`
5286
- );
5287
- }
5230
+ const protoJson = toExtensionProtoJson(name, extension);
5231
+ const structuralErrors = runProtoValidation(
5232
+ prefix,
5233
+ protoJson,
5234
+ (json) => fromJson3(ExtensionConfigSchema, json, { ignoreUnknownFields: true })
5235
+ );
5236
+ errors.push(...structuralErrors);
5288
5237
  const placements = extension.placements ?? [];
5289
- if (placements.length === 0) {
5290
- errors.push(`${prefix}: placements must declare at least one placement`);
5291
- }
5292
5238
  placements.forEach((placement, index) => {
5293
- const placementPrefix = `${prefix} placements[${index}]`;
5294
- if (!placement.target || !KNOWN_EXTENSION_TARGETS.has(placement.target)) {
5239
+ if (placement.model && !modelsByKey.has(placement.model)) {
5295
5240
  errors.push(
5296
- `${placementPrefix}: target must be one of ${[...KNOWN_EXTENSION_TARGETS].join(", ")}`
5241
+ `${prefix} placements[${index}]: missing model '${placement.model}' referenced by extension '${name}'`
5297
5242
  );
5298
5243
  }
5299
- if (!placement.model || typeof placement.model !== "string") {
5300
- errors.push(`${placementPrefix}: model is required`);
5301
- } else if (!modelsByKey.has(placement.model)) {
5302
- errors.push(
5303
- `${placementPrefix}: missing model '${placement.model}' referenced by extension '${name}'`
5304
- );
5305
- }
5306
- if (!placement.tab || typeof placement.tab !== "string") {
5307
- errors.push(`${placementPrefix}: tab is required`);
5308
- }
5309
- if (!placement.title || typeof placement.title !== "string") {
5310
- errors.push(`${placementPrefix}: title is required`);
5311
- }
5312
5244
  });
5313
5245
  }
5314
- function isWellFormedHttpsUrl(candidate) {
5246
+ function runProtoValidation(prefix, json, decode) {
5247
+ let message;
5248
+ try {
5249
+ message = decode(json);
5250
+ } catch (err) {
5251
+ const msg = err instanceof Error ? err.message : String(err);
5252
+ return [`${prefix}: cannot decode as proto \u2014 ${msg}`];
5253
+ }
5254
+ const result = runValidate(message);
5255
+ if (result.kind === "valid") return [];
5256
+ if (result.kind === "error") {
5257
+ return [`${prefix}: validator error \u2014 ${result.error.message}`];
5258
+ }
5259
+ return result.violations.map((v) => `${prefix}: ${formatViolation(v)}`);
5260
+ }
5261
+ function runValidate(message) {
5262
+ if (!message || typeof message !== "object") {
5263
+ return { kind: "error", error: new Error("message is not an object") };
5264
+ }
5265
+ const anyMsg = message;
5266
+ let schema;
5267
+ if (anyMsg.$typeName === "integrations.v1.IntegrationConfig") {
5268
+ schema = IntegrationConfigSchema;
5269
+ } else if (anyMsg.$typeName === "integrations.v1.ExtensionConfig") {
5270
+ schema = ExtensionConfigSchema;
5271
+ } else {
5272
+ return { kind: "error", error: new Error(`unknown message type ${anyMsg.$typeName ?? "?"}`) };
5273
+ }
5274
+ const result = validator.validate(
5275
+ // Both schema/message casts are safe because we picked schema by $typeName.
5276
+ schema,
5277
+ message
5278
+ );
5279
+ if (result.kind === "valid") return { kind: "valid" };
5280
+ if (result.kind === "error") return { kind: "error", error: result.error };
5281
+ return { kind: "invalid", violations: result.violations };
5282
+ }
5283
+ function formatViolation(v) {
5284
+ let path3 = "";
5315
5285
  try {
5316
- const parsed = new URL(candidate);
5317
- if (parsed.protocol !== "https:") return false;
5318
- if (!parsed.hostname) return false;
5319
- return true;
5286
+ path3 = pathToString(v.field);
5320
5287
  } catch {
5321
- return false;
5288
+ path3 = "";
5289
+ }
5290
+ const message = v.message ?? "";
5291
+ return path3 ? `${path3}: ${message}` : message;
5292
+ }
5293
+ function compact(obj) {
5294
+ const out = {};
5295
+ for (const [k, v] of Object.entries(obj)) {
5296
+ if (v !== void 0) out[k] = v;
5297
+ }
5298
+ return out;
5299
+ }
5300
+ function toIntegrationProtoJson(name, integration) {
5301
+ return compact({
5302
+ name,
5303
+ enabled: integration.enabled ?? true,
5304
+ middleware: integration.middleware ? { url: integration.middleware.url ?? "" } : void 0,
5305
+ credentials: {
5306
+ strategy: integration.credentials?.strategy ? credentialStrategyToProto(integration.credentials.strategy) : "CREDENTIAL_STRATEGY_UNSPECIFIED"
5307
+ },
5308
+ sync: integration.sync ? Object.fromEntries(
5309
+ Object.entries(integration.sync).map(([k, v]) => [k, syncMappingToProto(v)])
5310
+ ) : {},
5311
+ settings: integration.settings,
5312
+ metadata: integration.metadata
5313
+ });
5314
+ }
5315
+ function syncMappingToProto(mapping) {
5316
+ return compact({
5317
+ model: mapping.model ?? "",
5318
+ naturalKey: mapping.naturalKey ?? "",
5319
+ fields: mapping.fields ?? {},
5320
+ modelSeed: mapping.modelSeed ? {
5321
+ fields: Object.fromEntries(
5322
+ Object.entries(mapping.modelSeed.fields ?? {}).map(([k, v]) => [
5323
+ k,
5324
+ compact({
5325
+ type: v.type ?? "",
5326
+ required: v.required ?? false,
5327
+ naturalKey: v.naturalKey ?? false,
5328
+ label: v.label,
5329
+ helpText: v.helpText,
5330
+ config: v.config
5331
+ })
5332
+ ])
5333
+ )
5334
+ } : void 0
5335
+ });
5336
+ }
5337
+ function credentialStrategyToProto(strategy) {
5338
+ switch (strategy) {
5339
+ case "oauth":
5340
+ return "CREDENTIAL_STRATEGY_OAUTH";
5341
+ case "api_key":
5342
+ return "CREDENTIAL_STRATEGY_API_KEY";
5343
+ case "shared_secret":
5344
+ return "CREDENTIAL_STRATEGY_SHARED_SECRET";
5345
+ case "ssh_key":
5346
+ return "CREDENTIAL_STRATEGY_SSH_KEY";
5347
+ case "none":
5348
+ return "CREDENTIAL_STRATEGY_NONE";
5349
+ case "managed":
5350
+ return "CREDENTIAL_STRATEGY_MANAGED";
5351
+ default:
5352
+ return "CREDENTIAL_STRATEGY_UNSPECIFIED";
5353
+ }
5354
+ }
5355
+ function toExtensionProtoJson(name, extension) {
5356
+ const placements = (extension.placements ?? []).map(
5357
+ (p) => compact({
5358
+ target: extensionTargetToProto(p.target),
5359
+ model: p.model ?? "",
5360
+ tab: p.tab ?? "",
5361
+ title: p.title ?? "",
5362
+ hints: p.hints
5363
+ })
5364
+ );
5365
+ return compact({
5366
+ name,
5367
+ url: extension.url ?? "",
5368
+ placements,
5369
+ metadata: extension.metadata
5370
+ });
5371
+ }
5372
+ function extensionTargetToProto(target) {
5373
+ switch (target) {
5374
+ case "record":
5375
+ return "EXTENSION_TARGET_RECORD";
5376
+ case "model-list":
5377
+ return "EXTENSION_TARGET_MODEL_LIST";
5378
+ default:
5379
+ return "EXTENSION_TARGET_UNSPECIFIED";
5322
5380
  }
5323
5381
  }
5324
5382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eide/foir-cli",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "description": "Universal platform CLI for Foir platform",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,9 +47,10 @@
47
47
  "license": "UNLICENSED",
48
48
  "dependencies": {
49
49
  "@bufbuild/protobuf": "^2.0.0",
50
+ "@bufbuild/protovalidate": "^1.1.1",
50
51
  "@connectrpc/connect": "^2.0.0",
51
52
  "@connectrpc/connect-node": "^2.0.0",
52
- "@eide/foir-proto-ts": "^0.3.8",
53
+ "@eide/foir-proto-ts": "^0.6.1",
53
54
  "chalk": "^5.3.0",
54
55
  "commander": "^12.1.0",
55
56
  "dotenv": "^16.4.5",