@eide/foir-cli 0.4.11 → 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.
package/dist/cli.js CHANGED
@@ -3146,10 +3146,13 @@ async function createPlatformClient(options) {
3146
3146
  headers["Authorization"] = `Bearer ${credentials.accessToken}`;
3147
3147
  }
3148
3148
  const resolved = await resolveProjectContext(options);
3149
- if (resolved) {
3150
- headers["x-tenant-id"] = resolved.project.tenantId;
3151
- headers["x-project-id"] = resolved.project.id;
3149
+ if (!resolved) {
3150
+ throw new Error(
3151
+ "No project selected. Run `foir select-project` to choose a project."
3152
+ );
3152
3153
  }
3154
+ headers["x-tenant-id"] = resolved.project.tenantId;
3155
+ headers["x-project-id"] = resolved.project.id;
3153
3156
  const authInterceptor = (next) => async (req) => {
3154
3157
  for (const [key, value] of Object.entries(headers)) {
3155
3158
  req.header.set(key, value);
@@ -3240,10 +3243,13 @@ async function getStorageAuth(options) {
3240
3243
  baseHeaders["Authorization"] = `Bearer ${credentials.accessToken}`;
3241
3244
  }
3242
3245
  const resolved = await resolveProjectContext(options);
3243
- if (resolved) {
3244
- baseHeaders["x-tenant-id"] = resolved.project.tenantId;
3245
- baseHeaders["x-project-id"] = resolved.project.id;
3246
+ if (!resolved) {
3247
+ throw new Error(
3248
+ "No project selected. Run `foir select-project` to choose a project."
3249
+ );
3246
3250
  }
3251
+ baseHeaders["x-tenant-id"] = resolved.project.tenantId;
3252
+ baseHeaders["x-project-id"] = resolved.project.id;
3247
3253
  let cachedToken = null;
3248
3254
  const getToken = async () => {
3249
3255
  if (cachedToken && Date.now() < cachedToken.expiresAt - 3e4) {
@@ -5105,6 +5111,275 @@ async function reconcileApiKeys(client, apiKeys, summary) {
5105
5111
  }
5106
5112
  }
5107
5113
 
5114
+ // src/lib/validate-integrations.ts
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";
5122
+ var IntegrationValidationError = class extends Error {
5123
+ errors;
5124
+ constructor(errors) {
5125
+ super(
5126
+ `foir.config.ts failed validation:
5127
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
5128
+ );
5129
+ this.name = "IntegrationValidationError";
5130
+ this.errors = errors;
5131
+ }
5132
+ };
5133
+ var validator = createValidator();
5134
+ function validateIntegrationsAndExtensions(manifest) {
5135
+ const errors = [];
5136
+ const warnings = [];
5137
+ const modelsByKey = /* @__PURE__ */ new Map();
5138
+ for (const model of manifest.models ?? []) {
5139
+ modelsByKey.set(model.key, model);
5140
+ }
5141
+ if (manifest.integrations) {
5142
+ for (const [name, integration] of Object.entries(manifest.integrations)) {
5143
+ validateIntegration(name, integration, modelsByKey, errors, warnings);
5144
+ }
5145
+ }
5146
+ if (manifest.extensions) {
5147
+ for (const [name, extension] of Object.entries(manifest.extensions)) {
5148
+ validateExtension(name, extension, modelsByKey, errors);
5149
+ }
5150
+ }
5151
+ return { errors, warnings };
5152
+ }
5153
+ function assertValid(result) {
5154
+ if (result.errors.length > 0) {
5155
+ throw new IntegrationValidationError(result.errors);
5156
+ }
5157
+ }
5158
+ function validateIntegration(name, integration, modelsByKey, errors, warnings) {
5159
+ const prefix = `integration '${name}'`;
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);
5167
+ const sync = integration.sync ?? {};
5168
+ for (const [sourceType, mapping] of Object.entries(sync)) {
5169
+ validateSyncMapping(
5170
+ `${prefix} sync.${sourceType}`,
5171
+ mapping,
5172
+ name,
5173
+ modelsByKey,
5174
+ errors,
5175
+ warnings
5176
+ );
5177
+ }
5178
+ }
5179
+ function validateSyncMapping(prefix, mapping, integrationName, modelsByKey, errors, warnings) {
5180
+ const model = mapping.model ? modelsByKey.get(mapping.model) : void 0;
5181
+ const seed = mapping.modelSeed;
5182
+ if (!mapping.model) {
5183
+ return;
5184
+ }
5185
+ if (!model && !seed) {
5186
+ errors.push(
5187
+ `${prefix}: missing model '${mapping.model}' referenced by integration '${integrationName}' \u2014 declare it under models or provide a modelSeed`
5188
+ );
5189
+ return;
5190
+ }
5191
+ const fieldKeys = model ? new Set((model.fields ?? []).map((f) => f.key)) : new Set(Object.keys(seed?.fields ?? {}));
5192
+ if (mapping.naturalKey && !fieldKeys.has(mapping.naturalKey)) {
5193
+ errors.push(
5194
+ `${prefix}: naturalKey '${mapping.naturalKey}' does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5195
+ );
5196
+ }
5197
+ const fields = mapping.fields ?? {};
5198
+ for (const [sourceField, foirField] of Object.entries(fields)) {
5199
+ if (!foirField) continue;
5200
+ if (!fieldKeys.has(foirField)) {
5201
+ errors.push(
5202
+ `${prefix}: field mapping '${sourceField} -> ${foirField}' references field '${foirField}' which does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5203
+ );
5204
+ }
5205
+ }
5206
+ if (seed && model) {
5207
+ detectSeedDrift(prefix, seed, model, warnings);
5208
+ }
5209
+ }
5210
+ function detectSeedDrift(prefix, seed, model, warnings) {
5211
+ const modelFieldTypes = /* @__PURE__ */ new Map();
5212
+ for (const field of model.fields ?? []) {
5213
+ modelFieldTypes.set(field.key, field.type);
5214
+ }
5215
+ for (const [key, field] of Object.entries(seed.fields ?? {})) {
5216
+ const modelType = modelFieldTypes.get(key);
5217
+ if (!modelType) {
5218
+ warnings.push(
5219
+ `${prefix}: modelSeed.fields.${key} is not present on existing model \u2014 modelSeed is ignored on subsequent pushes`
5220
+ );
5221
+ } else if (field.type && modelType !== field.type) {
5222
+ warnings.push(
5223
+ `${prefix}: modelSeed.fields.${key}.type '${field.type}' disagrees with existing model field type '${modelType}' \u2014 modelSeed is ignored on subsequent pushes`
5224
+ );
5225
+ }
5226
+ }
5227
+ }
5228
+ function validateExtension(name, extension, modelsByKey, errors) {
5229
+ const prefix = `extension '${name}'`;
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);
5237
+ const placements = extension.placements ?? [];
5238
+ placements.forEach((placement, index) => {
5239
+ if (placement.model && !modelsByKey.has(placement.model)) {
5240
+ errors.push(
5241
+ `${prefix} placements[${index}]: missing model '${placement.model}' referenced by extension '${name}'`
5242
+ );
5243
+ }
5244
+ });
5245
+ }
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 = "";
5285
+ try {
5286
+ path3 = pathToString(v.field);
5287
+ } catch {
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";
5380
+ }
5381
+ }
5382
+
5108
5383
  // src/commands/push.ts
5109
5384
  var CONFIG_FILE_NAMES = [
5110
5385
  "foir.config.ts",
@@ -5181,6 +5456,11 @@ function registerPushCommand(program2, globalOpts) {
5181
5456
  'Config must have at least "key" and "name" fields.'
5182
5457
  );
5183
5458
  }
5459
+ const validation = validateIntegrationsAndExtensions(config2);
5460
+ for (const warning of validation.warnings) {
5461
+ console.log(chalk6.yellow(`\u26A0 ${warning}`));
5462
+ }
5463
+ assertValid(validation);
5184
5464
  const client = await createPlatformClient(globalOpts());
5185
5465
  console.log(
5186
5466
  chalk6.dim(`Pushing config "${config2.key}" to platform...`)
@@ -166,6 +166,57 @@ interface ApplyConfigApiKeyInput {
166
166
  /** Restrict file uploads to specific MIME types (e.g. ["image/*", "video/*"]). */
167
167
  allowedFileTypes?: string[];
168
168
  }
169
+ type CredentialStrategy = 'oauth' | 'api_key' | 'shared_secret' | 'ssh_key' | 'none' | 'managed';
170
+ interface IntegrationMiddlewareInput {
171
+ /** Full https URL of the middleware handling this integration. */
172
+ url: string;
173
+ }
174
+ interface IntegrationCredentialsInput {
175
+ strategy: CredentialStrategy;
176
+ }
177
+ interface ModelSeedFieldInput {
178
+ type: string;
179
+ required?: boolean;
180
+ naturalKey?: boolean;
181
+ label?: string;
182
+ helpText?: string;
183
+ config?: Record<string, unknown>;
184
+ }
185
+ interface ModelSeedInput {
186
+ fields: Record<string, ModelSeedFieldInput>;
187
+ }
188
+ interface IntegrationSyncMappingInput {
189
+ /** foir model key this source type writes into. */
190
+ model: string;
191
+ /** foir field holding the upstream natural key. UPSERTs are keyed on this. */
192
+ naturalKey: string;
193
+ /** Explicit source field -> foir field mapping. */
194
+ fields: Record<string, string>;
195
+ /** Optional one-time bootstrap schema if the target model is missing. */
196
+ modelSeed?: ModelSeedInput;
197
+ }
198
+ interface IntegrationInput {
199
+ enabled?: boolean;
200
+ middleware: IntegrationMiddlewareInput;
201
+ credentials: IntegrationCredentialsInput;
202
+ sync: Record<string, IntegrationSyncMappingInput>;
203
+ /** Opaque middleware-specific settings — not validated by the CLI. */
204
+ settings?: Record<string, unknown>;
205
+ metadata?: Record<string, unknown>;
206
+ }
207
+ type ExtensionTarget = 'record' | 'model-list';
208
+ interface ExtensionPlacementInput {
209
+ target: ExtensionTarget;
210
+ model: string;
211
+ tab: string;
212
+ title: string;
213
+ hints?: Record<string, unknown>;
214
+ }
215
+ interface ExtensionInput {
216
+ url: string;
217
+ placements: ExtensionPlacementInput[];
218
+ metadata?: Record<string, unknown>;
219
+ }
169
220
  interface ApplyConfigInput {
170
221
  key: string;
171
222
  name: string;
@@ -181,6 +232,10 @@ interface ApplyConfigInput {
181
232
  authProviders?: ApplyConfigAuthProviderInput[];
182
233
  placements?: ApplyConfigPlacementInput[];
183
234
  apiKeys?: ApplyConfigApiKeyInput[];
235
+ /** Per-project integration declarations, keyed by integration name. */
236
+ integrations?: Record<string, IntegrationInput>;
237
+ /** Per-project extension declarations, keyed by extension name. */
238
+ extensions?: Record<string, ExtensionInput>;
184
239
  [key: string]: unknown;
185
240
  }
186
241
  /** Define a complete config manifest. */
@@ -203,5 +258,9 @@ declare function defineAuthProvider(provider: ApplyConfigAuthProviderInput): App
203
258
  declare function defineHook(hook: ApplyConfigHookInput): ApplyConfigHookInput;
204
259
  /** Define an editor placement (sidebar or main-editor tab). */
205
260
  declare function definePlacement(placement: ApplyConfigPlacementInput): ApplyConfigPlacementInput;
261
+ /** Define an integration declaration. */
262
+ declare function defineIntegration(integration: IntegrationInput): IntegrationInput;
263
+ /** Define an extension declaration. */
264
+ declare function defineExtensionDeclaration(extension: ExtensionInput): ExtensionInput;
206
265
 
207
- export { type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type ExpressionPrecondition, type FieldDefinitionInput, type Precondition, type QuotaRule, type SegmentPrecondition, defineAuthProvider, defineConfig, defineExtension, defineField, defineHook, defineModel, defineOperation, definePlacement, defineSchedule, defineSegment };
266
+ export { type ApplyConfigApiKeyInput, type ApplyConfigAuthProviderInput, type ApplyConfigHookInput, type ApplyConfigInput, type ApplyConfigModelInput, type ApplyConfigOperationInput, type ApplyConfigPlacementInput, type ApplyConfigScheduleInput, type ApplyConfigSegmentInput, type CredentialStrategy, type ExpressionPrecondition, type ExtensionInput, type ExtensionPlacementInput, type ExtensionTarget, type FieldDefinitionInput, type IntegrationCredentialsInput, type IntegrationInput, type IntegrationMiddlewareInput, type IntegrationSyncMappingInput, type ModelSeedFieldInput, type ModelSeedInput, type Precondition, type QuotaRule, type SegmentPrecondition, defineAuthProvider, defineConfig, defineExtension, defineExtensionDeclaration, defineField, defineHook, defineIntegration, defineModel, defineOperation, definePlacement, defineSchedule, defineSegment };
@@ -27,12 +27,20 @@ function defineHook(hook) {
27
27
  function definePlacement(placement) {
28
28
  return placement;
29
29
  }
30
+ function defineIntegration(integration) {
31
+ return integration;
32
+ }
33
+ function defineExtensionDeclaration(extension) {
34
+ return extension;
35
+ }
30
36
  export {
31
37
  defineAuthProvider,
32
38
  defineConfig,
33
39
  defineExtension,
40
+ defineExtensionDeclaration,
34
41
  defineField,
35
42
  defineHook,
43
+ defineIntegration,
36
44
  defineModel,
37
45
  defineOperation,
38
46
  definePlacement,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eide/foir-cli",
3
- "version": "0.4.11",
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",