@eide/foir-cli 0.4.11 → 0.4.14

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,217 @@ async function reconcileApiKeys(client, apiKeys, summary) {
5105
5111
  }
5106
5112
  }
5107
5113
 
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"]);
5125
+ var IntegrationValidationError = class extends Error {
5126
+ errors;
5127
+ constructor(errors) {
5128
+ super(
5129
+ `foir.config.ts failed validation:
5130
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
5131
+ );
5132
+ this.name = "IntegrationValidationError";
5133
+ this.errors = errors;
5134
+ }
5135
+ };
5136
+ function validateIntegrationsAndExtensions(manifest) {
5137
+ const errors = [];
5138
+ const warnings = [];
5139
+ const modelsByKey = /* @__PURE__ */ new Map();
5140
+ for (const model of manifest.models ?? []) {
5141
+ modelsByKey.set(model.key, model);
5142
+ }
5143
+ if (manifest.integrations) {
5144
+ for (const [name, integration] of Object.entries(manifest.integrations)) {
5145
+ validateIntegration(name, integration, modelsByKey, errors, warnings);
5146
+ }
5147
+ }
5148
+ if (manifest.extensions) {
5149
+ for (const [name, extension] of Object.entries(manifest.extensions)) {
5150
+ validateExtension(name, extension, modelsByKey, errors);
5151
+ }
5152
+ }
5153
+ return { errors, warnings };
5154
+ }
5155
+ function assertValid(result) {
5156
+ if (result.errors.length > 0) {
5157
+ throw new IntegrationValidationError(result.errors);
5158
+ }
5159
+ }
5160
+ function validateIntegration(name, integration, modelsByKey, errors, warnings) {
5161
+ 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
+ }
5181
+ 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
+ for (const [sourceType, mapping] of Object.entries(sync)) {
5186
+ validateSyncMapping(
5187
+ `${prefix} sync.${sourceType}`,
5188
+ mapping,
5189
+ name,
5190
+ modelsByKey,
5191
+ errors,
5192
+ warnings
5193
+ );
5194
+ }
5195
+ }
5196
+ function validateSyncMapping(prefix, mapping, integrationName, modelsByKey, errors, warnings) {
5197
+ if (!mapping.model || typeof mapping.model !== "string") {
5198
+ errors.push(`${prefix}: model is required`);
5199
+ return;
5200
+ }
5201
+ const model = modelsByKey.get(mapping.model);
5202
+ const seed = mapping.modelSeed;
5203
+ if (!model && !seed) {
5204
+ errors.push(
5205
+ `${prefix}: missing model '${mapping.model}' referenced by integration '${integrationName}' \u2014 declare it under models or provide a modelSeed`
5206
+ );
5207
+ return;
5208
+ }
5209
+ 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)) {
5214
+ errors.push(
5215
+ `${prefix}: naturalKey '${naturalKeyField}' does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5216
+ );
5217
+ }
5218
+ 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
+ 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
+ }
5229
+ if (!fieldKeys.has(foirField)) {
5230
+ errors.push(
5231
+ `${prefix}: field mapping '${sourceField} -> ${foirField}' references field '${foirField}' which does not exist on ${model ? `model '${mapping.model}'` : `modelSeed for '${mapping.model}'`}`
5232
+ );
5233
+ }
5234
+ }
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
+ }
5256
+ }
5257
+ }
5258
+ function detectSeedDrift(prefix, seed, model, warnings) {
5259
+ const modelFieldTypes = /* @__PURE__ */ new Map();
5260
+ for (const field of model.fields ?? []) {
5261
+ modelFieldTypes.set(field.key, field.type);
5262
+ }
5263
+ for (const [key, field] of Object.entries(seed.fields ?? {})) {
5264
+ const modelType = modelFieldTypes.get(key);
5265
+ if (!modelType) {
5266
+ warnings.push(
5267
+ `${prefix}: modelSeed.fields.${key} is not present on existing model \u2014 modelSeed is ignored on subsequent pushes`
5268
+ );
5269
+ } else if (field.type && modelType !== field.type) {
5270
+ warnings.push(
5271
+ `${prefix}: modelSeed.fields.${key}.type '${field.type}' disagrees with existing model field type '${modelType}' \u2014 modelSeed is ignored on subsequent pushes`
5272
+ );
5273
+ }
5274
+ }
5275
+ }
5276
+ function validateExtension(name, extension, modelsByKey, errors) {
5277
+ 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
+ }
5288
+ const placements = extension.placements ?? [];
5289
+ if (placements.length === 0) {
5290
+ errors.push(`${prefix}: placements must declare at least one placement`);
5291
+ }
5292
+ placements.forEach((placement, index) => {
5293
+ const placementPrefix = `${prefix} placements[${index}]`;
5294
+ if (!placement.target || !KNOWN_EXTENSION_TARGETS.has(placement.target)) {
5295
+ errors.push(
5296
+ `${placementPrefix}: target must be one of ${[...KNOWN_EXTENSION_TARGETS].join(", ")}`
5297
+ );
5298
+ }
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
+ });
5313
+ }
5314
+ function isWellFormedHttpsUrl(candidate) {
5315
+ try {
5316
+ const parsed = new URL(candidate);
5317
+ if (parsed.protocol !== "https:") return false;
5318
+ if (!parsed.hostname) return false;
5319
+ return true;
5320
+ } catch {
5321
+ return false;
5322
+ }
5323
+ }
5324
+
5108
5325
  // src/commands/push.ts
5109
5326
  var CONFIG_FILE_NAMES = [
5110
5327
  "foir.config.ts",
@@ -5181,6 +5398,11 @@ function registerPushCommand(program2, globalOpts) {
5181
5398
  'Config must have at least "key" and "name" fields.'
5182
5399
  );
5183
5400
  }
5401
+ const validation = validateIntegrationsAndExtensions(config2);
5402
+ for (const warning of validation.warnings) {
5403
+ console.log(chalk6.yellow(`\u26A0 ${warning}`));
5404
+ }
5405
+ assertValid(validation);
5184
5406
  const client = await createPlatformClient(globalOpts());
5185
5407
  console.log(
5186
5408
  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.4.14",
4
4
  "description": "Universal platform CLI for Foir platform",
5
5
  "type": "module",
6
6
  "publishConfig": {