From 2f6736a205d23d87b0c2eb148d69f2c6ffe2b738 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 3 Feb 2026 18:49:37 +0100 Subject: [PATCH 1/9] chore: plugin manifest definition chore: make plugin manifest required All plugins must now declare a static manifest property with their metadata and resource requirements. This simplifies the manifest loader and provides better error messages. --- docs/docs/api/appkit/Class.Plugin.md | 43 ++++- .../api/appkit/Enumeration.ResourceType.md | 73 ++++++++ .../docs/api/appkit/Interface.ConfigSchema.md | 52 ++++++ .../appkit/Interface.ConfigSchemaProperty.md | 83 +++++++++ .../api/appkit/Interface.PluginManifest.md | 124 +++++++++++++ .../api/appkit/Interface.ResourceEntry.md | 123 +++++++++++++ .../appkit/Interface.ResourceRequirement.md | 69 +++++++ .../api/appkit/Interface.ValidationResult.md | 33 ++++ .../appkit/TypeAlias.ResourcePermission.md | 8 + docs/docs/api/appkit/index.md | 15 +- docs/docs/api/appkit/typedoc-sidebar.ts | 46 +++++ .../appkit/src/core/tests/databricks.test.ts | 26 +++ packages/appkit/src/index.ts | 11 ++ packages/appkit/src/plugin/plugin.ts | 47 ++++- .../appkit/src/plugins/analytics/analytics.ts | 4 + .../appkit/src/plugins/analytics/index.ts | 1 + .../appkit/src/plugins/analytics/manifest.ts | 49 +++++ packages/appkit/src/plugins/server/index.ts | 8 + .../appkit/src/plugins/server/manifest.ts | 47 +++++ .../server/tests/server.integration.test.ts | 10 + packages/appkit/src/registry/index.ts | 14 ++ packages/appkit/src/registry/types.ts | 174 ++++++++++++++++++ packages/shared/src/plugin.ts | 53 ++++++ 23 files changed, 1110 insertions(+), 3 deletions(-) create mode 100644 docs/docs/api/appkit/Enumeration.ResourceType.md create mode 100644 docs/docs/api/appkit/Interface.ConfigSchema.md create mode 100644 docs/docs/api/appkit/Interface.ConfigSchemaProperty.md create mode 100644 docs/docs/api/appkit/Interface.PluginManifest.md create mode 100644 docs/docs/api/appkit/Interface.ResourceEntry.md create mode 100644 docs/docs/api/appkit/Interface.ResourceRequirement.md create mode 100644 docs/docs/api/appkit/Interface.ValidationResult.md create mode 100644 docs/docs/api/appkit/TypeAlias.ResourcePermission.md create mode 100644 packages/appkit/src/plugins/analytics/manifest.ts create mode 100644 packages/appkit/src/plugins/server/manifest.ts create mode 100644 packages/appkit/src/registry/index.ts create mode 100644 packages/appkit/src/registry/types.ts diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index a741189e..19fd0e9e 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -1,6 +1,40 @@ # Abstract Class: Plugin\ -Base abstract class for creating AppKit plugins +Base abstract class for creating AppKit plugins. + +Plugins can optionally declare their resource requirements through: +1. A static `manifest` property - recommended for all plugins +2. A static `getResourceRequirements()` method - for dynamic requirements + +## Example + +```typescript +import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; + +// Define manifest +const myManifest: PluginManifest = { + name: 'myPlugin', + displayName: 'My Plugin', + description: 'Does something awesome', + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: 'warehouse', + description: 'SQL Warehouse for queries', + permission: 'CAN_USE', + env: 'DATABRICKS_WAREHOUSE_ID' + } + ], + optional: [] + } +}; + +class MyPlugin extends Plugin { + static manifest = myManifest; + // ... implementation +} +``` ## Type Parameters @@ -86,6 +120,8 @@ protected isReady: boolean = false; name: string; ``` +Plugin name identifier. + #### Implementation of ```ts @@ -116,6 +152,11 @@ protected telemetry: ITelemetry; static phase: PluginPhase = "normal"; ``` +Plugin initialization phase. +- 'core': Initialized first (e.g., config plugins) +- 'normal': Initialized second (most plugins) +- 'deferred': Initialized last (e.g., server plugin) + ## Methods ### abortActiveOperations() diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md new file mode 100644 index 00000000..53241e47 --- /dev/null +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -0,0 +1,73 @@ +# Enumeration: ResourceType + +Supported Databricks resource types that plugins can depend on. + +## Enumeration Members + +### JOB + +```ts +JOB: "job"; +``` + +Databricks Job for scheduled or triggered workflows + +*** + +### LAKEBASE + +```ts +LAKEBASE: "lakebase"; +``` + +Lakebase instance for persistent caching or data storage + +*** + +### SECRET\_SCOPE + +```ts +SECRET_SCOPE: "secret-scope"; +``` + +Secret scope for secure credential storage + +*** + +### SERVING\_ENDPOINT + +```ts +SERVING_ENDPOINT: "serving-endpoint"; +``` + +Model serving endpoint for ML inference + +*** + +### SQL\_WAREHOUSE + +```ts +SQL_WAREHOUSE: "sql-warehouse"; +``` + +Databricks SQL Warehouse for query execution + +*** + +### UNITY\_CATALOG + +```ts +UNITY_CATALOG: "unity-catalog"; +``` + +Unity Catalog for data governance and metadata + +*** + +### VECTOR\_SEARCH\_INDEX + +```ts +VECTOR_SEARCH_INDEX: "vector-search-index"; +``` + +Vector search index for similarity search diff --git a/docs/docs/api/appkit/Interface.ConfigSchema.md b/docs/docs/api/appkit/Interface.ConfigSchema.md new file mode 100644 index 00000000..5ff1c797 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ConfigSchema.md @@ -0,0 +1,52 @@ +# Interface: ConfigSchema + +Configuration schema definition for plugin config. +Uses JSON Schema format for validation and documentation. + +## Indexable + +```ts +[key: string]: unknown +``` + +Allow additional JSON Schema properties + +## Properties + +### additionalProperties? + +```ts +optional additionalProperties: boolean; +``` + +*** + +### items? + +```ts +optional items: ConfigSchema; +``` + +*** + +### properties? + +```ts +optional properties: Record; +``` + +*** + +### required? + +```ts +optional required: string[]; +``` + +*** + +### type + +```ts +type: "string" | "number" | "boolean" | "object" | "array"; +``` diff --git a/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md b/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md new file mode 100644 index 00000000..c8fd10cd --- /dev/null +++ b/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md @@ -0,0 +1,83 @@ +# Interface: ConfigSchemaProperty + +Individual property definition in a config schema. + +## Properties + +### default? + +```ts +optional default: unknown; +``` + +*** + +### description? + +```ts +optional description: string; +``` + +*** + +### enum? + +```ts +optional enum: unknown[]; +``` + +*** + +### items? + +```ts +optional items: ConfigSchemaProperty; +``` + +*** + +### maximum? + +```ts +optional maximum: number; +``` + +*** + +### maxLength? + +```ts +optional maxLength: number; +``` + +*** + +### minimum? + +```ts +optional minimum: number; +``` + +*** + +### minLength? + +```ts +optional minLength: number; +``` + +*** + +### properties? + +```ts +optional properties: Record; +``` + +*** + +### type + +```ts +type: "string" | "number" | "boolean" | "object" | "array"; +``` diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md new file mode 100644 index 00000000..00251a9f --- /dev/null +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -0,0 +1,124 @@ +# Interface: PluginManifest + +Plugin manifest that declares metadata and resource requirements. +Attached to plugin classes as a static property. + +## Properties + +### author? + +```ts +optional author: string; +``` + +Optional metadata for community plugins + +*** + +### config? + +```ts +optional config: { + schema: ConfigSchema; +}; +``` + +Configuration schema for the plugin. +Defines the shape and validation rules for plugin config. + +#### schema + +```ts +schema: ConfigSchema; +``` + +*** + +### description + +```ts +description: string; +``` + +Brief description of what the plugin does + +*** + +### displayName + +```ts +displayName: string; +``` + +Human-readable display name for UI/CLI + +*** + +### keywords? + +```ts +optional keywords: string[]; +``` + +*** + +### license? + +```ts +optional license: string; +``` + +*** + +### name + +```ts +name: string; +``` + +Plugin identifier (matches plugin.name) + +*** + +### repository? + +```ts +optional repository: string; +``` + +*** + +### resources + +```ts +resources: { + optional: Omit[]; + required: Omit[]; +}; +``` + +Resource requirements declaration + +#### optional + +```ts +optional: Omit[]; +``` + +Resources that enhance functionality but are not mandatory + +#### required + +```ts +required: Omit[]; +``` + +Resources that must be available for the plugin to function + +*** + +### version? + +```ts +optional version: string; +``` diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md new file mode 100644 index 00000000..5d962219 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -0,0 +1,123 @@ +# Interface: ResourceEntry + +Internal representation of a resource in the registry. +Extends ResourceRequirement with resolution state and plugin ownership. + +## Extends + +- [`ResourceRequirement`](Interface.ResourceRequirement.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`alias`](Interface.ResourceRequirement.md#alias) + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`description`](Interface.ResourceRequirement.md#description) + +*** + +### env? + +```ts +optional env: string; +``` + +Environment variable name where the resource ID/value should be provided +Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`env`](Interface.ResourceRequirement.md#env) + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`permission`](Interface.ResourceRequirement.md#permission) + +*** + +### plugin + +```ts +plugin: string; +``` + +Plugin(s) that require this resource (comma-separated if multiple) + +*** + +### required + +```ts +required: boolean; +``` + +Whether this resource is required (true) or optional (false) + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`required`](Interface.ResourceRequirement.md#required) + +*** + +### resolved + +```ts +resolved: boolean; +``` + +Whether the resource has been resolved (environment variable found) + +*** + +### type + +```ts +type: ResourceType; +``` + +Type of Databricks resource required + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`type`](Interface.ResourceRequirement.md#type) + +*** + +### value? + +```ts +optional value: string; +``` + +The actual value of the resource (if resolved) diff --git a/docs/docs/api/appkit/Interface.ResourceRequirement.md b/docs/docs/api/appkit/Interface.ResourceRequirement.md new file mode 100644 index 00000000..86893314 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceRequirement.md @@ -0,0 +1,69 @@ +# Interface: ResourceRequirement + +Declares a resource requirement for a plugin. +Can be defined statically in a manifest or dynamically via getResourceRequirements(). + +## Extended by + +- [`ResourceEntry`](Interface.ResourceEntry.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +*** + +### env? + +```ts +optional env: string; +``` + +Environment variable name where the resource ID/value should be provided +Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +*** + +### required + +```ts +required: boolean; +``` + +Whether this resource is required (true) or optional (false) + +*** + +### type + +```ts +type: ResourceType; +``` + +Type of Databricks resource required diff --git a/docs/docs/api/appkit/Interface.ValidationResult.md b/docs/docs/api/appkit/Interface.ValidationResult.md new file mode 100644 index 00000000..f71a4bce --- /dev/null +++ b/docs/docs/api/appkit/Interface.ValidationResult.md @@ -0,0 +1,33 @@ +# Interface: ValidationResult + +Result of validating all registered resources against the environment. + +## Properties + +### all + +```ts +all: ResourceEntry[]; +``` + +Complete list of all registered resources (required and optional) + +*** + +### missing + +```ts +missing: ResourceEntry[]; +``` + +List of missing required resources + +*** + +### valid + +```ts +valid: boolean; +``` + +Whether all required resources are available diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md new file mode 100644 index 00000000..eb91fd57 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -0,0 +1,8 @@ +# Type Alias: ResourcePermission + +```ts +type ResourcePermission = "CAN_USE" | "CAN_MANAGE" | "CAN_VIEW" | "READ" | "WRITE" | "EXECUTE"; +``` + +Permission levels that can be required for a resource. +Based on Databricks permission model. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 772f3db3..b3fd4ccd 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -3,6 +3,12 @@ Core library for building Databricks applications with type-safe SQL queries, plugin architecture, and React integration. +## Enumerations + +| Enumeration | Description | +| ------ | ------ | +| [ResourceType](Enumeration.ResourceType.md) | Supported Databricks resource types that plugins can depend on. | + ## Classes | Class | Description | @@ -13,7 +19,7 @@ plugin architecture, and React integration. | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | -| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins | +| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | @@ -24,15 +30,22 @@ plugin architecture, and React integration. | ------ | ------ | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for caching | +| [ConfigSchema](Interface.ConfigSchema.md) | Configuration schema definition for plugin config. Uses JSON Schema format for validation and documentation. | +| [ConfigSchemaProperty](Interface.ConfigSchemaProperty.md) | Individual property definition in a config schema. | | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | +| [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. | +| [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | +| [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Configuration for streaming execution with default and user-scoped settings | | [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications | +| [ValidationResult](Interface.ValidationResult.md) | Result of validating all registered resources against the environment. | ## Type Aliases | Type Alias | Description | | ------ | ------ | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | +| [ResourcePermission](TypeAlias.ResourcePermission.md) | Permission levels that can be required for a resource. Based on Databricks permission model. | ## Variables diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 8fd695d5..93381150 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -1,6 +1,17 @@ import { SidebarsConfig } from "@docusaurus/plugin-content-docs"; const typedocSidebar: SidebarsConfig = { items: [ + { + type: "category", + label: "Enumerations", + items: [ + { + type: "doc", + id: "api/appkit/Enumeration.ResourceType", + label: "ResourceType" + } + ] + }, { type: "category", label: "Classes", @@ -71,11 +82,36 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, + { + type: "doc", + id: "api/appkit/Interface.ConfigSchema", + label: "ConfigSchema" + }, + { + type: "doc", + id: "api/appkit/Interface.ConfigSchemaProperty", + label: "ConfigSchemaProperty" + }, { type: "doc", id: "api/appkit/Interface.ITelemetry", label: "ITelemetry" }, + { + type: "doc", + id: "api/appkit/Interface.PluginManifest", + label: "PluginManifest" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceEntry", + label: "ResourceEntry" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceRequirement", + label: "ResourceRequirement" + }, { type: "doc", id: "api/appkit/Interface.StreamExecutionSettings", @@ -85,6 +121,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/Interface.TelemetryConfig", label: "TelemetryConfig" + }, + { + type: "doc", + id: "api/appkit/Interface.ValidationResult", + label: "ValidationResult" } ] }, @@ -96,6 +137,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.ResourcePermission", + label: "ResourcePermission" } ] }, diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index bc2df7a4..381b5136 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -4,6 +4,19 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; import { AppKit, createApp } from "../appkit"; +// Helper function to create test manifests +function createTestManifest(name: string, displayName: string) { + return { + name, + displayName, + description: `Test plugin for ${name}`, + resources: { + required: [], + optional: [], + }, + }; +} + // Mock environment validation vi.mock("../utils", () => ({ validateEnv: vi.fn(), @@ -32,6 +45,7 @@ vi.mock("@databricks-apps/cache", () => ({ class CoreTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { coreDefault: "core-value" }; static phase = "core" as const; + static manifest = createTestManifest("coreTest", "Core Test Plugin"); name = "coreTest"; setupCalled = false; validateEnvCalled = false; @@ -68,6 +82,7 @@ class CoreTestPlugin implements BasePlugin { class NormalTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { normalDefault: "normal-value" }; static phase = "normal" as const; + static manifest = createTestManifest("normalTest", "Normal Test Plugin"); name = "normalTest"; setupCalled = false; validateEnvCalled = false; @@ -103,6 +118,7 @@ class NormalTestPlugin implements BasePlugin { class DeferredTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { deferredDefault: "deferred-value" }; static phase = "deferred" as const; + static manifest = createTestManifest("deferredTest", "Deferred Test Plugin"); name = "deferredTest"; setupCalled = false; validateEnvCalled = false; @@ -140,6 +156,7 @@ class DeferredTestPlugin implements BasePlugin { class SlowSetupPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("slowSetup", "Slow Setup Plugin"); name = "slowSetup"; setupDelay: number; setupCalled = false; @@ -170,6 +187,7 @@ class SlowSetupPlugin implements BasePlugin { class FailingPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("failing", "Failing Plugin"); name = "failing"; validateEnv() { @@ -527,6 +545,10 @@ describe("AppKit", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest( + "contextTest", + "Context Test Plugin", + ); name = "contextTest"; private counter = 0; @@ -567,6 +589,10 @@ describe("AppKit", () => { test("should maintain context when SDK method is passed as callback", async () => { class CallbackTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest( + "callbackTest", + "Callback Test Plugin", + ); name = "callbackTest"; private values: number[] = []; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 12165794..e4becb67 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,6 +31,17 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, server } from "./plugins"; +// Registry types for plugin manifests +export type { + ConfigSchema, + ConfigSchemaProperty, + PluginManifest, + ResourceEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./registry"; +export { ResourceType } from "./registry"; // Telemetry (for advanced custom telemetry) export { type Counter, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index c5050ca9..10f06ed7 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -59,7 +59,42 @@ const EXCLUDED_FROM_PROXY = new Set([ "constructor", ]); -/** Base abstract class for creating AppKit plugins */ +/** + * Base abstract class for creating AppKit plugins. + * + * Plugins can optionally declare their resource requirements through: + * 1. A static `manifest` property - recommended for all plugins + * 2. A static `getResourceRequirements()` method - for dynamic requirements + * + * @example + * ```typescript + * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; + * + * // Define manifest + * const myManifest: PluginManifest = { + * name: 'myPlugin', + * displayName: 'My Plugin', + * description: 'Does something awesome', + * resources: { + * required: [ + * { + * type: ResourceType.SQL_WAREHOUSE, + * alias: 'warehouse', + * description: 'SQL Warehouse for queries', + * permission: 'CAN_USE', + * env: 'DATABRICKS_WAREHOUSE_ID' + * } + * ], + * optional: [] + * } + * }; + * + * class MyPlugin extends Plugin { + * static manifest = myManifest; + * // ... implementation + * } + * ``` + */ export abstract class Plugin< TConfig extends BasePluginConfig = BasePluginConfig, > implements BasePlugin @@ -75,7 +110,17 @@ export abstract class Plugin< /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; + /** + * Plugin initialization phase. + * - 'core': Initialized first (e.g., config plugins) + * - 'normal': Initialized second (most plugins) + * - 'deferred': Initialized last (e.g., server plugin) + */ static phase: PluginPhase = "normal"; + + /** + * Plugin name identifier. + */ name: string; constructor(protected config: TConfig) { diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index a631a776..cc590436 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -15,6 +15,7 @@ import { import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { queryDefaults } from "./defaults"; +import { analyticsManifest } from "./manifest"; import { QueryProcessor } from "./query"; import type { AnalyticsQueryResponse, @@ -28,6 +29,9 @@ export class AnalyticsPlugin extends Plugin { name = "analytics"; protected envVars: string[] = []; + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = analyticsManifest; + protected static description = "Analytics plugin for data analysis"; protected declare config: IAnalyticsConfig; diff --git a/packages/appkit/src/plugins/analytics/index.ts b/packages/appkit/src/plugins/analytics/index.ts index 9ad02125..56774782 100644 --- a/packages/appkit/src/plugins/analytics/index.ts +++ b/packages/appkit/src/plugins/analytics/index.ts @@ -1,2 +1,3 @@ export * from "./analytics"; +export * from "./manifest"; export * from "./types"; diff --git a/packages/appkit/src/plugins/analytics/manifest.ts b/packages/appkit/src/plugins/analytics/manifest.ts new file mode 100644 index 00000000..bc431b93 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.ts @@ -0,0 +1,49 @@ +import type { PluginManifest } from "../../registry"; +import { ResourceType } from "../../registry"; + +/** + * Analytics plugin manifest. + * + * The analytics plugin requires a SQL Warehouse for executing queries + * against Databricks data sources. + */ +export const analyticsManifest: PluginManifest = { + name: "analytics", + displayName: "Analytics Plugin", + description: "SQL query execution against Databricks SQL Warehouses", + + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "SQL Warehouse for executing analytics queries", + permission: "CAN_USE", + env: "DATABRICKS_WAREHOUSE_ID", + }, + ], + optional: [], + }, + + config: { + schema: { + type: "object", + properties: { + timeout: { + type: "number", + default: 30000, + description: "Query execution timeout in milliseconds", + }, + queriesDir: { + type: "string", + description: "Directory containing SQL query files", + }, + cacheEnabled: { + type: "boolean", + default: true, + description: "Enable query result caching", + }, + }, + }, + }, +}; diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 62b3e7bd..61228f35 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -8,6 +8,7 @@ import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { instrumentations } from "../../telemetry"; +import { serverManifest } from "./manifest"; import { RemoteTunnelController } from "./remote-tunnel/remote-tunnel-controller"; import { StaticServer } from "./static-server"; import type { ServerConfig } from "./types"; @@ -39,6 +40,9 @@ export class ServerPlugin extends Plugin { port: Number(process.env.DATABRICKS_APP_PORT) || 8000, }; + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = serverManifest; + public name = "server" as const; protected envVars: string[] = []; private serverApplication: express.Application; @@ -355,3 +359,7 @@ export const server = toPlugin( ServerPlugin, "server", ); + +// Export manifest and types +export { serverManifest } from "./manifest"; +export type { ServerConfig } from "./types"; diff --git a/packages/appkit/src/plugins/server/manifest.ts b/packages/appkit/src/plugins/server/manifest.ts new file mode 100644 index 00000000..0973230e --- /dev/null +++ b/packages/appkit/src/plugins/server/manifest.ts @@ -0,0 +1,47 @@ +import type { PluginManifest } from "../../registry"; + +/** + * Server plugin manifest. + * + * The server plugin doesn't require any Databricks resources - it only + * provides HTTP server functionality and static file serving. + */ +export const serverManifest: PluginManifest = { + name: "server", + displayName: "Server Plugin", + description: + "HTTP server with Express, static file serving, and Vite dev mode support", + + resources: { + required: [], + optional: [], + }, + + config: { + schema: { + type: "object", + properties: { + autoStart: { + type: "boolean", + default: true, + description: "Automatically start the server on plugin setup", + }, + host: { + type: "string", + default: "0.0.0.0", + description: "Host address to bind the server to", + }, + port: { + type: "number", + default: 8000, + description: "Port number for the server", + }, + staticPath: { + type: "string", + description: + "Path to static files directory (auto-detected if not provided)", + }, + }, + }, + }, +}; diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index c752f797..580b9969 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -101,6 +101,16 @@ describe("ServerPlugin with custom plugin", () => { name = "test-plugin" as const; envVars: string[] = []; + static manifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "Test plugin for integration tests", + resources: { + required: [], + optional: [], + }, + }; + injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { res.json({ message: "hello from test plugin" }); diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts new file mode 100644 index 00000000..a663e44a --- /dev/null +++ b/packages/appkit/src/registry/index.ts @@ -0,0 +1,14 @@ +/** + * Resource Registry System + * + * The registry system enables plugins to declare their Databricks resource + * requirements (SQL Warehouses, Lakebase instances, etc.) in a standardized way. + * + * Components: + * - Type definitions for resources, manifests, and validation + * - (Future) ResourceRegistry singleton for tracking requirements + * - (Future) Manifest loader for reading plugin declarations + * - (Future) Config generators for app.yaml, databricks.yml, .env.example + */ + +export * from "./types"; diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts new file mode 100644 index 00000000..18216521 --- /dev/null +++ b/packages/appkit/src/registry/types.ts @@ -0,0 +1,174 @@ +/** + * Resource Registry Type System + * + * This module defines the type system for the AppKit Resource Registry, + * which enables plugins to declare their Databricks resource requirements + * in a machine-readable format. + */ + +/** + * Supported Databricks resource types that plugins can depend on. + */ +export enum ResourceType { + /** Databricks SQL Warehouse for query execution */ + SQL_WAREHOUSE = "sql-warehouse", + + /** Lakebase instance for persistent caching or data storage */ + LAKEBASE = "lakebase", + + /** Databricks Job for scheduled or triggered workflows */ + JOB = "job", + + /** Secret scope for secure credential storage */ + SECRET_SCOPE = "secret-scope", + + /** Model serving endpoint for ML inference */ + SERVING_ENDPOINT = "serving-endpoint", + + /** Vector search index for similarity search */ + VECTOR_SEARCH_INDEX = "vector-search-index", + + /** Unity Catalog for data governance and metadata */ + UNITY_CATALOG = "unity-catalog", +} + +/** + * Permission levels that can be required for a resource. + * Based on Databricks permission model. + */ +export type ResourcePermission = + | "CAN_USE" + | "CAN_MANAGE" + | "CAN_VIEW" + | "READ" + | "WRITE" + | "EXECUTE"; + +/** + * Declares a resource requirement for a plugin. + * Can be defined statically in a manifest or dynamically via getResourceRequirements(). + */ +export interface ResourceRequirement { + /** Type of Databricks resource required */ + type: ResourceType; + + /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') */ + alias: string; + + /** Human-readable description of why this resource is needed */ + description: string; + + /** Required permission level for the resource */ + permission: ResourcePermission; + + /** + * Environment variable name where the resource ID/value should be provided + * Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + */ + env?: string; + + /** Whether this resource is required (true) or optional (false) */ + required: boolean; +} + +/** + * Internal representation of a resource in the registry. + * Extends ResourceRequirement with resolution state and plugin ownership. + */ +export interface ResourceEntry extends ResourceRequirement { + /** Plugin(s) that require this resource (comma-separated if multiple) */ + plugin: string; + + /** Whether the resource has been resolved (environment variable found) */ + resolved: boolean; + + /** The actual value of the resource (if resolved) */ + value?: string; +} + +/** + * Result of validating all registered resources against the environment. + */ +export interface ValidationResult { + /** Whether all required resources are available */ + valid: boolean; + + /** List of missing required resources */ + missing: ResourceEntry[]; + + /** Complete list of all registered resources (required and optional) */ + all: ResourceEntry[]; +} + +/** + * Configuration schema definition for plugin config. + * Uses JSON Schema format for validation and documentation. + */ +export interface ConfigSchema { + type: "object" | "array" | "string" | "number" | "boolean"; + properties?: Record; + items?: ConfigSchema; + required?: string[]; + additionalProperties?: boolean; + /** Allow additional JSON Schema properties */ + [key: string]: unknown; +} + +/** + * Individual property definition in a config schema. + */ +export interface ConfigSchemaProperty { + type: "object" | "array" | "string" | "number" | "boolean"; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + items?: ConfigSchemaProperty; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; +} + +/** + * Plugin manifest that declares metadata and resource requirements. + * Attached to plugin classes as a static property. + */ +export interface PluginManifest { + /** Plugin identifier (matches plugin.name) */ + name: string; + + /** Human-readable display name for UI/CLI */ + displayName: string; + + /** Brief description of what the plugin does */ + description: string; + + /** + * Resource requirements declaration + */ + resources: { + /** Resources that must be available for the plugin to function */ + required: Omit[]; + + /** Resources that enhance functionality but are not mandatory */ + optional: Omit[]; + }; + + /** + * Configuration schema for the plugin. + * Defines the shape and validation rules for plugin config. + */ + config?: { + schema: ConfigSchema; + }; + + /** + * Optional metadata for community plugins + */ + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index a30260aa..e390f835 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -46,6 +46,10 @@ export interface PluginConfig { export type PluginPhase = "core" | "normal" | "deferred"; +/** + * Plugin constructor with required manifest declaration. + * All plugins must declare a manifest with their metadata and resource requirements. + */ export type PluginConstructor< C = BasePluginConfig, I extends BasePlugin = BasePlugin, @@ -54,8 +58,57 @@ export type PluginConstructor< ) => I) & { DEFAULT_CONFIG?: Record; phase?: PluginPhase; + /** + * Static manifest declaring plugin metadata and resource requirements. + * Required for all plugins. + */ + manifest: PluginManifest; + /** + * Optional runtime resource requirements based on config. + * Use this when resource requirements depend on plugin configuration. + */ + getResourceRequirements?(config: C): ResourceRequirement[]; }; +/** + * Manifest declaration for plugins (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: Omit[]; + optional: Omit[]; + }; + config?: { + schema: { + type: string; + properties?: Record; + [key: string]: unknown; + }; + }; + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} + +/** + * Resource requirement declaration (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface ResourceRequirement { + type: string; + alias: string; + description: string; + permission: string; + env?: string; + required: boolean; +} + export type ConfigFor = T extends { DEFAULT_CONFIG: infer D } ? D : T extends new ( From a0eb32da57ac5e440a1cba1b2598010bf4013b41 Mon Sep 17 00:00:00 2001 From: Mario Cadenas <17888484+MarioCadenas@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:24:49 +0100 Subject: [PATCH 2/9] chore: implement manifest loader (#83) Implement manifest loader with validation and error handling. All plugins must have a static manifest property. Features: - getPluginManifest() - loads and validates plugin manifests - getResourceRequirements() - extracts resource requirements - Comprehensive validation with clear error messages - Unit tests and integration tests with core plugins --- .../appkit/src/core/tests/databricks.test.ts | 43 +- packages/appkit/src/index.ts | 8 +- packages/appkit/src/plugin/plugin.ts | 24 +- .../server/tests/server.integration.test.ts | 10 +- packages/appkit/src/registry/index.ts | 3 +- .../appkit/src/registry/manifest-loader.ts | 164 ++++++ .../src/registry/tests/integration.test.ts | 49 ++ .../registry/tests/manifest-loader.test.ts | 486 ++++++++++++++++++ 8 files changed, 746 insertions(+), 41 deletions(-) create mode 100644 packages/appkit/src/registry/manifest-loader.ts create mode 100644 packages/appkit/src/registry/tests/integration.test.ts create mode 100644 packages/appkit/src/registry/tests/manifest-loader.test.ts diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 381b5136..6b4abe0d 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -2,20 +2,19 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import type { BasePlugin } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; +import type { PluginManifest } from "../../registry/types"; import { AppKit, createApp } from "../appkit"; -// Helper function to create test manifests -function createTestManifest(name: string, displayName: string) { - return { - name, - displayName, - description: `Test plugin for ${name}`, - resources: { - required: [], - optional: [], - }, - }; -} +// Generic test manifest for test plugins +const createTestManifest = (name: string): PluginManifest => ({ + name, + displayName: `${name} Test Plugin`, + description: `Test plugin for ${name}`, + resources: { + required: [], + optional: [], + }, +}); // Mock environment validation vi.mock("../utils", () => ({ @@ -45,7 +44,7 @@ vi.mock("@databricks-apps/cache", () => ({ class CoreTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { coreDefault: "core-value" }; static phase = "core" as const; - static manifest = createTestManifest("coreTest", "Core Test Plugin"); + static manifest = createTestManifest("coreTest"); name = "coreTest"; setupCalled = false; validateEnvCalled = false; @@ -82,7 +81,7 @@ class CoreTestPlugin implements BasePlugin { class NormalTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { normalDefault: "normal-value" }; static phase = "normal" as const; - static manifest = createTestManifest("normalTest", "Normal Test Plugin"); + static manifest = createTestManifest("normalTest"); name = "normalTest"; setupCalled = false; validateEnvCalled = false; @@ -118,7 +117,7 @@ class NormalTestPlugin implements BasePlugin { class DeferredTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { deferredDefault: "deferred-value" }; static phase = "deferred" as const; - static manifest = createTestManifest("deferredTest", "Deferred Test Plugin"); + static manifest = createTestManifest("deferredTest"); name = "deferredTest"; setupCalled = false; validateEnvCalled = false; @@ -156,7 +155,7 @@ class DeferredTestPlugin implements BasePlugin { class SlowSetupPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest("slowSetup", "Slow Setup Plugin"); + static manifest = createTestManifest("slowSetup"); name = "slowSetup"; setupDelay: number; setupCalled = false; @@ -187,7 +186,7 @@ class SlowSetupPlugin implements BasePlugin { class FailingPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest("failing", "Failing Plugin"); + static manifest = createTestManifest("failing"); name = "failing"; validateEnv() { @@ -545,10 +544,7 @@ describe("AppKit", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest( - "contextTest", - "Context Test Plugin", - ); + static manifest = createTestManifest("contextTest"); name = "contextTest"; private counter = 0; @@ -589,10 +585,7 @@ describe("AppKit", () => { test("should maintain context when SDK method is passed as callback", async () => { class CallbackTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest( - "callbackTest", - "Callback Test Plugin", - ); + static manifest = createTestManifest("callbackTest"); name = "callbackTest"; private values: number[] = []; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index e4becb67..5fe94593 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,7 +31,7 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, server } from "./plugins"; -// Registry types for plugin manifests +// Registry types and utilities for plugin manifests export type { ConfigSchema, ConfigSchemaProperty, @@ -41,7 +41,11 @@ export type { ResourceRequirement, ValidationResult, } from "./registry"; -export { ResourceType } from "./registry"; +export { + getPluginManifest, + getResourceRequirements, + ResourceType, +} from "./registry"; // Telemetry (for advanced custom telemetry) export { type Counter, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 10f06ed7..4d9c168a 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -62,15 +62,15 @@ const EXCLUDED_FROM_PROXY = new Set([ /** * Base abstract class for creating AppKit plugins. * - * Plugins can optionally declare their resource requirements through: - * 1. A static `manifest` property - recommended for all plugins - * 2. A static `getResourceRequirements()` method - for dynamic requirements + * All plugins must declare a static `manifest` property with their metadata + * and resource requirements. Plugins can also implement a static + * `getResourceRequirements()` method for dynamic requirements based on config. * * @example * ```typescript * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; * - * // Define manifest + * // Define manifest (required) * const myManifest: PluginManifest = { * name: 'myPlugin', * displayName: 'My Plugin', @@ -90,9 +90,21 @@ const EXCLUDED_FROM_PROXY = new Set([ * }; * * class MyPlugin extends Plugin { - * static manifest = myManifest; - * // ... implementation + * static manifest = myManifest; // Required! + * + * name = 'myPlugin'; + * protected envVars: string[] = []; + * + * async setup() { + * // Initialize your plugin + * } + * + * injectRoutes(router: Router) { + * // Register HTTP endpoints + * } * } + * + * export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); * ``` */ export abstract class Plugin< diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index 580b9969..ded42c84 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -98,18 +98,14 @@ describe("ServerPlugin with custom plugin", () => { // Create a simple test plugin class TestPlugin extends Plugin { - name = "test-plugin" as const; - envVars: string[] = []; - static manifest = { name: "test-plugin", displayName: "Test Plugin", description: "Test plugin for integration tests", - resources: { - required: [], - optional: [], - }, + resources: { required: [], optional: [] }, }; + name = "test-plugin" as const; + envVars: string[] = []; injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index a663e44a..d5c0c07b 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -6,9 +6,10 @@ * * Components: * - Type definitions for resources, manifests, and validation + * - Manifest loader for reading plugin declarations * - (Future) ResourceRegistry singleton for tracking requirements - * - (Future) Manifest loader for reading plugin declarations * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ +export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; export * from "./types"; diff --git a/packages/appkit/src/registry/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts new file mode 100644 index 00000000..5e0dcab6 --- /dev/null +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -0,0 +1,164 @@ +import type { PluginConstructor } from "shared"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging/logger"; +import type { PluginManifest } from "./types"; + +const logger = createLogger("manifest-loader"); + +/** + * Loads and validates the manifest from a plugin constructor. + * + * All plugins must have a static `manifest` property that declares their + * metadata and resource requirements. + * + * @param plugin - The plugin constructor class + * @returns The validated plugin manifest + * @throws {ConfigurationError} If the manifest is missing or invalid + * + * @example + * ```typescript + * import { AnalyticsPlugin } from '@databricks/appkit'; + * import { getPluginManifest } from './manifest-loader'; + * + * const manifest = getPluginManifest(AnalyticsPlugin); + * console.log('Required resources:', manifest.resources.required); + * ``` + */ +export function getPluginManifest(plugin: PluginConstructor): PluginManifest { + const pluginName = plugin.name || "unknown"; + + try { + // Check for static manifest property + if (!plugin.manifest) { + throw new ConfigurationError( + `Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`, + ); + } + + // Validate manifest structure + const manifest = plugin.manifest; + + if (!manifest.name || typeof manifest.name !== "string") { + throw new ConfigurationError( + `Plugin ${pluginName} manifest has missing or invalid 'name' field`, + ); + } + + if (!manifest.displayName || typeof manifest.displayName !== "string") { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has missing or invalid 'displayName' field`, + ); + } + + if (!manifest.description || typeof manifest.description !== "string") { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has missing or invalid 'description' field`, + ); + } + + if (!manifest.resources) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest is missing 'resources' field`, + ); + } + + if (!Array.isArray(manifest.resources.required)) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has invalid 'resources.required' field (expected array)`, + ); + } + + if ( + manifest.resources.optional && + !Array.isArray(manifest.resources.optional) + ) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has invalid 'resources.optional' field (expected array)`, + ); + } + + logger.debug( + "Loaded manifest for plugin %s: %d required resources, %d optional resources", + manifest.name, + manifest.resources.required.length, + manifest.resources.optional?.length || 0, + ); + + // Cast to appkit PluginManifest type (structurally compatible, just more specific types) + return manifest as unknown as PluginManifest; + } catch (error) { + if (error instanceof ConfigurationError) { + throw error; + } + throw new ConfigurationError( + `Error loading manifest from plugin ${pluginName}: ${error}`, + ); + } +} + +/** + * Gets the resource requirements from a plugin's manifest. + * + * Combines required and optional resources into a single array with the + * `required` flag set appropriately. + * + * @param plugin - The plugin constructor class + * @returns Combined array of required and optional resources + * @throws {ConfigurationError} If the plugin manifest is missing or invalid + * + * @example + * ```typescript + * const resources = getResourceRequirements(AnalyticsPlugin); + * for (const resource of resources) { + * console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); + * } + * ``` + */ +export function getResourceRequirements(plugin: PluginConstructor) { + const manifest = getPluginManifest(plugin); + + const required = manifest.resources.required.map((r) => ({ + ...r, + required: true, + })); + const optional = (manifest.resources.optional || []).map((r) => ({ + ...r, + required: false, + })); + + return [...required, ...optional]; +} + +/** + * Validates a manifest object structure. + * + * @param manifest - The manifest object to validate + * @returns true if the manifest is valid, false otherwise + * + * @internal + */ +export function isValidManifest(manifest: unknown): manifest is PluginManifest { + if (!manifest || typeof manifest !== "object") { + return false; + } + + const m = manifest as Record; + + // Check required fields + if (typeof m.name !== "string") return false; + if (typeof m.displayName !== "string") return false; + if (typeof m.description !== "string") return false; + + // Check resources structure + if (!m.resources || typeof m.resources !== "object") return false; + + const resources = m.resources as Record; + if (!Array.isArray(resources.required)) return false; + + // Optional field can be missing or must be an array + if (resources.optional !== undefined && !Array.isArray(resources.optional)) { + return false; + } + + return true; +} diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts new file mode 100644 index 00000000..ce587758 --- /dev/null +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { AnalyticsPlugin } from "../../plugins/analytics/analytics"; +import { ServerPlugin } from "../../plugins/server"; +import { getPluginManifest, getResourceRequirements } from "../manifest-loader"; +import { ResourceType } from "../types"; + +describe("Manifest Loader Integration", () => { + describe("ServerPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(ServerPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("server"); + expect(manifest?.displayName).toBe("Server Plugin"); + }); + + it("should have no required resources", () => { + const resources = getResourceRequirements(ServerPlugin); + expect(resources).toHaveLength(0); + }); + }); + + describe("AnalyticsPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("analytics"); + expect(manifest?.displayName).toBe("Analytics Plugin"); + }); + + it("should require SQL Warehouse", () => { + const resources = getResourceRequirements(AnalyticsPlugin); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + required: true, + permission: "CAN_USE", + env: "DATABRICKS_WAREHOUSE_ID", + }); + }); + + it("should have correct resource description", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest?.resources.required[0].description).toBe( + "SQL Warehouse for executing analytics queries", + ); + }); + }); +}); diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts new file mode 100644 index 00000000..0578b9dc --- /dev/null +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -0,0 +1,486 @@ +import type { PluginConstructor } from "shared"; +import { describe, expect, it } from "vitest"; +import { ConfigurationError } from "../../errors"; +import { + getPluginManifest, + getResourceRequirements, + isValidManifest, +} from "../manifest-loader"; +import type { PluginManifest } from "../types"; +import { ResourceType } from "../types"; + +describe("Manifest Loader", () => { + describe("getPluginManifest", () => { + it("should return manifest for plugin with valid manifest", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(/missing a manifest/i); + }); + + it("should throw error for plugin with invalid manifest (missing name)", () => { + const invalidManifest = { + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'name' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing displayName)", () => { + const invalidManifest = { + name: "test-plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'displayName' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing description)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'description' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing resources)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/missing 'resources' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.required not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: "not-an-array", + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.required' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.optional not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: "not-an-array", + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.optional' field/i); + }); + + it("should handle plugin with optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + }); + + describe("getResourceRequirements", () => { + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getResourceRequirements( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + }); + + it("should return required resources with required=true", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + required: true, + }); + }); + + it("should return optional resources with required=false", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + required: false, + }); + }); + + it("should return both required and optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(2); + expect(resources[0].required).toBe(true); + expect(resources[1].required).toBe(false); + }); + + it("should return empty array for plugin with no resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(0); + }); + }); + + describe("isValidManifest", () => { + it("should return true for valid manifest", () => { + const validManifest: PluginManifest = { + name: "test", + displayName: "Test", + description: "Test plugin", + resources: { + required: [], + optional: [], + }, + }; + + expect(isValidManifest(validManifest)).toBe(true); + }); + + it("should return false for null", () => { + expect(isValidManifest(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isValidManifest(undefined)).toBe(false); + }); + + it("should return false for non-object", () => { + expect(isValidManifest("string")).toBe(false); + expect(isValidManifest(123)).toBe(false); + expect(isValidManifest(true)).toBe(false); + }); + + it("should return false for manifest missing name", () => { + const invalid = { + displayName: "Test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing displayName", () => { + const invalid = { + name: "test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing description", () => { + const invalid = { + name: "test", + displayName: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing resources", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array required", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: "not-array", + optional: [], + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array optional", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: "not-array", + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return true for manifest without optional field", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + }, + }; + + expect(isValidManifest(valid)).toBe(true); + }); + + it("should return true for manifest with additional fields", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: [], + }, + author: "Test Author", + version: "1.0.0", + keywords: ["test"], + }; + + expect(isValidManifest(valid)).toBe(true); + }); + }); +}); From 7b9afa83ce3b04ec282bf17cd467623d62707e0b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 4 Feb 2026 14:50:21 +0100 Subject: [PATCH 3/9] chore: fixup --- docs/docs/api/appkit/Class.Plugin.md | 24 ++++++++--- .../api/appkit/Function.getPluginManifest.md | 36 +++++++++++++++++ .../Function.getResourceRequirements.md | 40 +++++++++++++++++++ docs/docs/api/appkit/index.md | 2 + docs/docs/api/appkit/typedoc-sidebar.ts | 10 +++++ 5 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 docs/docs/api/appkit/Function.getPluginManifest.md create mode 100644 docs/docs/api/appkit/Function.getResourceRequirements.md diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 19fd0e9e..796b31fb 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -2,16 +2,16 @@ Base abstract class for creating AppKit plugins. -Plugins can optionally declare their resource requirements through: -1. A static `manifest` property - recommended for all plugins -2. A static `getResourceRequirements()` method - for dynamic requirements +All plugins must declare a static `manifest` property with their metadata +and resource requirements. Plugins can also implement a static +`getResourceRequirements()` method for dynamic requirements based on config. ## Example ```typescript import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; -// Define manifest +// Define manifest (required) const myManifest: PluginManifest = { name: 'myPlugin', displayName: 'My Plugin', @@ -31,9 +31,21 @@ const myManifest: PluginManifest = { }; class MyPlugin extends Plugin { - static manifest = myManifest; - // ... implementation + static manifest = myManifest; // Required! + + name = 'myPlugin'; + protected envVars: string[] = []; + + async setup() { + // Initialize your plugin + } + + injectRoutes(router: Router) { + // Register HTTP endpoints + } } + +export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); ``` ## Type Parameters diff --git a/docs/docs/api/appkit/Function.getPluginManifest.md b/docs/docs/api/appkit/Function.getPluginManifest.md new file mode 100644 index 00000000..3afb325d --- /dev/null +++ b/docs/docs/api/appkit/Function.getPluginManifest.md @@ -0,0 +1,36 @@ +# Function: getPluginManifest() + +```ts +function getPluginManifest(plugin: PluginConstructor): PluginManifest; +``` + +Loads and validates the manifest from a plugin constructor. + +All plugins must have a static `manifest` property that declares their +metadata and resource requirements. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +[`PluginManifest`](Interface.PluginManifest.md) + +The validated plugin manifest + +## Throws + +If the manifest is missing or invalid + +## Example + +```typescript +import { AnalyticsPlugin } from '@databricks/appkit'; +import { getPluginManifest } from './manifest-loader'; + +const manifest = getPluginManifest(AnalyticsPlugin); +console.log('Required resources:', manifest.resources.required); +``` diff --git a/docs/docs/api/appkit/Function.getResourceRequirements.md b/docs/docs/api/appkit/Function.getResourceRequirements.md new file mode 100644 index 00000000..12ea5068 --- /dev/null +++ b/docs/docs/api/appkit/Function.getResourceRequirements.md @@ -0,0 +1,40 @@ +# Function: getResourceRequirements() + +```ts +function getResourceRequirements(plugin: PluginConstructor): { + alias: string; + description: string; + env?: string; + permission: ResourcePermission; + required: boolean; + type: ResourceType; +}[]; +``` + +Gets the resource requirements from a plugin's manifest. + +Combines required and optional resources into a single array with the +`required` flag set appropriately. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +Combined array of required and optional resources + +## Throws + +If the plugin manifest is missing or invalid + +## Example + +```typescript +const resources = getResourceRequirements(AnalyticsPlugin); +for (const resource of resources) { + console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); +} +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index b3fd4ccd..ac912bf2 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -60,4 +60,6 @@ plugin architecture, and React integration. | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | +| [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. | +| [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 93381150..c310eb62 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -175,6 +175,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.getExecutionContext", label: "getExecutionContext" }, + { + type: "doc", + id: "api/appkit/Function.getPluginManifest", + label: "getPluginManifest" + }, + { + type: "doc", + id: "api/appkit/Function.getResourceRequirements", + label: "getResourceRequirements" + }, { type: "doc", id: "api/appkit/Function.isSQLTypeMarker", From 8175a0ccbcd411bd7999ae5b2eacbc13075f9321 Mon Sep 17 00:00:00 2001 From: Mario Cadenas <17888484+MarioCadenas@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:17:52 +0100 Subject: [PATCH 4/9] chore: resource registry (#85) --- .../dev-playground/server/reconnect-plugin.ts | 11 +- .../server/telemetry-example-plugin.ts | 11 +- docs/docs/api/appkit/Class.Plugin.md | 102 ++-- .../docs/api/appkit/Class.ResourceRegistry.md | 288 ++++++++++ .../api/appkit/Enumeration.ResourceType.md | 83 ++- .../Function.getResourceRequirements.md | 3 +- .../api/appkit/Interface.ResourceEntry.md | 34 +- .../appkit/Interface.ResourceFieldEntry.md | 24 + .../appkit/Interface.ResourceRequirement.md | 20 +- .../appkit/TypeAlias.ResourcePermission.md | 17 +- docs/docs/api/appkit/index.md | 6 +- docs/docs/api/appkit/typedoc-sidebar.ts | 10 + docs/docs/plugins.md | 76 ++- docs/package.json | 3 +- docs/scripts/copy-schemas.ts | 47 ++ .../schemas/plugin-manifest.schema.json | 326 +++++++++++ .../schemas/template-plugins.schema.json | 179 ++++++ packages/appkit/src/core/appkit.ts | 10 +- .../appkit/src/core/tests/databricks.test.ts | 58 +- packages/appkit/src/index.ts | 2 + packages/appkit/src/plugin/plugin.ts | 85 ++- .../appkit/src/plugin/tests/plugin.test.ts | 25 - .../appkit/src/plugins/analytics/analytics.ts | 1 - .../src/plugins/analytics/manifest.json | 45 ++ .../appkit/src/plugins/analytics/manifest.ts | 53 +- packages/appkit/src/plugins/server/index.ts | 1 - .../appkit/src/plugins/server/manifest.json | 36 ++ .../appkit/src/plugins/server/manifest.ts | 51 +- .../server/tests/server.integration.test.ts | 1 - .../src/plugins/server/tests/server.test.ts | 19 +- packages/appkit/src/registry/index.ts | 23 +- .../appkit/src/registry/resource-registry.ts | 427 +++++++++++++++ .../schemas/plugin-manifest.schema.json | 326 +++++++++++ .../schemas/template-plugins.schema.json | 179 ++++++ .../src/registry/tests/integration.test.ts | 12 +- .../registry/tests/manifest-loader.test.ts | 93 +++- .../registry/tests/resource-registry.test.ts | 261 +++++++++ packages/appkit/src/registry/types.ts | 139 ++++- packages/appkit/src/utils/env-validator.ts | 15 - packages/appkit/src/utils/index.ts | 1 - packages/appkit/tsdown.config.ts | 19 + packages/shared/bin/appkit.js | 0 .../shared/src/cli/commands/plugins-sync.ts | 505 +++++++++++++++++ packages/shared/src/cli/commands/plugins.ts | 16 + packages/shared/src/cli/index.ts | 2 + packages/shared/src/plugin.ts | 2 - template/.env.example.tmpl | 2 +- template/appkit.plugins.json | 41 ++ template/databricks.yml.tmpl | 9 +- template/features/analytics/app_env.yml | 2 - .../features/analytics/bundle_resources.yml | 4 - .../features/analytics/bundle_variables.yml | 2 - template/features/analytics/dotenv.yml | 1 - .../features/analytics/dotenv_example.yml | 1 - .../features/analytics/target_variables.yml | 1 - template/package-lock.json | 511 +----------------- template/server/server.ts | 5 +- 57 files changed, 3356 insertions(+), 870 deletions(-) create mode 100644 docs/docs/api/appkit/Class.ResourceRegistry.md create mode 100644 docs/docs/api/appkit/Interface.ResourceFieldEntry.md create mode 100644 docs/scripts/copy-schemas.ts create mode 100644 docs/static/schemas/plugin-manifest.schema.json create mode 100644 docs/static/schemas/template-plugins.schema.json create mode 100644 packages/appkit/src/plugins/analytics/manifest.json create mode 100644 packages/appkit/src/plugins/server/manifest.json create mode 100644 packages/appkit/src/registry/resource-registry.ts create mode 100644 packages/appkit/src/registry/schemas/plugin-manifest.schema.json create mode 100644 packages/appkit/src/registry/schemas/template-plugins.schema.json create mode 100644 packages/appkit/src/registry/tests/resource-registry.test.ts delete mode 100644 packages/appkit/src/utils/env-validator.ts mode change 100644 => 100755 packages/shared/bin/appkit.js create mode 100644 packages/shared/src/cli/commands/plugins-sync.ts create mode 100644 packages/shared/src/cli/commands/plugins.ts create mode 100644 template/appkit.plugins.json delete mode 100644 template/features/analytics/app_env.yml delete mode 100644 template/features/analytics/bundle_resources.yml delete mode 100644 template/features/analytics/bundle_variables.yml delete mode 100644 template/features/analytics/dotenv.yml delete mode 100644 template/features/analytics/dotenv_example.yml delete mode 100644 template/features/analytics/target_variables.yml diff --git a/apps/dev-playground/server/reconnect-plugin.ts b/apps/dev-playground/server/reconnect-plugin.ts index 949de36c..908b0e1a 100644 --- a/apps/dev-playground/server/reconnect-plugin.ts +++ b/apps/dev-playground/server/reconnect-plugin.ts @@ -15,7 +15,16 @@ interface ReconnectStreamResponse { export class ReconnectPlugin extends Plugin { public name = "reconnect"; - protected envVars: string[] = []; + + static manifest = { + name: "reconnect", + displayName: "Reconnect Plugin", + description: "A plugin that reconnects to the server", + resources: { + required: [], + optional: [], + }, + }; injectRoutes(router: IAppRouter): void { this.route(router, { diff --git a/apps/dev-playground/server/telemetry-example-plugin.ts b/apps/dev-playground/server/telemetry-example-plugin.ts index 714bbbef..8f879687 100644 --- a/apps/dev-playground/server/telemetry-example-plugin.ts +++ b/apps/dev-playground/server/telemetry-example-plugin.ts @@ -17,7 +17,16 @@ import type { Request, Response, Router } from "express"; class TelemetryExamples extends Plugin { public name = "telemetry-examples" as const; - protected envVars: string[] = []; + + static manifest = { + name: "telemetry-examples", + displayName: "Telemetry Examples Plugin", + description: "A plugin that provides telemetry examples", + resources: { + required: [], + optional: [], + }, + }; private requestCounter: Counter; private durationHistogram: Histogram; diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 796b31fb..64de5830 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -3,49 +3,85 @@ Base abstract class for creating AppKit plugins. All plugins must declare a static `manifest` property with their metadata -and resource requirements. Plugins can also implement a static -`getResourceRequirements()` method for dynamic requirements based on config. +and resource requirements. The manifest defines: +- `required` resources: Always needed for the plugin to function +- `optional` resources: May be needed depending on plugin configuration -## Example +## Static vs Runtime Resource Requirements + +The manifest is static and doesn't know the plugin's runtime configuration. +For resources that become required based on config options, plugins can +implement a static `getResourceRequirements(config)` method. + +At runtime, this method is called with the actual config to determine +which "optional" resources should be treated as "required". + +## Examples ```typescript import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; -// Define manifest (required) const myManifest: PluginManifest = { name: 'myPlugin', displayName: 'My Plugin', description: 'Does something awesome', resources: { required: [ - { - type: ResourceType.SQL_WAREHOUSE, - alias: 'warehouse', - description: 'SQL Warehouse for queries', - permission: 'CAN_USE', - env: 'DATABRICKS_WAREHOUSE_ID' - } + { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } ], optional: [] } }; class MyPlugin extends Plugin { - static manifest = myManifest; // Required! - + static manifest = myManifest; name = 'myPlugin'; - protected envVars: string[] = []; +} +``` - async setup() { - // Initialize your plugin +```typescript +interface MyConfig extends BasePluginConfig { + enableCaching?: boolean; +} + +const myManifest: PluginManifest = { + name: 'myPlugin', + resources: { + required: [ + { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + ], + optional: [ + // Database is optional in the static manifest + { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... } + ] } +}; - injectRoutes(router: Router) { - // Register HTTP endpoints +class MyPlugin extends Plugin { + static manifest = myManifest; + name = 'myPlugin'; + + // Runtime method: converts optional resources to required based on config + static getResourceRequirements(config: MyConfig) { + const resources = []; + if (config.enableCaching) { + // When caching is enabled, Database becomes required + resources.push({ + type: ResourceType.DATABASE, + alias: 'cache', + resourceKey: 'database', + description: 'Cache storage for query results', + permission: 'CAN_CONNECT_AND_CREATE', + fields: { + instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' }, + database_name: { env: 'DATABRICKS_CACHE_DB' }, + }, + required: true // Mark as required at runtime + }); + } + return resources; } } - -export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); ``` ## Type Parameters @@ -110,14 +146,6 @@ protected devFileReader: DevFileReader; *** -### envVars - -```ts -abstract protected envVars: string[]; -``` - -*** - ### isReady ```ts @@ -400,21 +428,3 @@ setup(): Promise; ```ts BasePlugin.setup ``` - -*** - -### validateEnv() - -```ts -validateEnv(): void; -``` - -#### Returns - -`void` - -#### Implementation of - -```ts -BasePlugin.validateEnv -``` diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md new file mode 100644 index 00000000..6a964c39 --- /dev/null +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -0,0 +1,288 @@ +# Class: ResourceRegistry + +Central registry for tracking plugin resource requirements. +Implements singleton pattern to ensure a single source of truth. + +## Methods + +### clear() + +```ts +clear(): void; +``` + +Clears all registered resources. +Useful for testing or when rebuilding the registry. + +#### Returns + +`void` + +*** + +### collectResources() + +```ts +collectResources(rawPlugins: PluginData[]): void; +``` + +Collects and registers resource requirements from an array of plugins. +For each plugin, loads its manifest to discover static resource declarations, +then checks for runtime resource requirements via `getResourceRequirements()`. + +Plugins without manifests are silently skipped (allowed for legacy plugins +or plugins that don't declare resources). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `rawPlugins` | `PluginData`\<`PluginConstructor`, `unknown`, `string`\>[] | Array of plugin data entries from createApp configuration | + +#### Returns + +`void` + +*** + +### enforceValidation() + +```ts +enforceValidation(): ValidationResult; +``` + +Validates all registered resources and enforces the result. + +- In production: throws a [ConfigurationError](Class.ConfigurationError.md) if any required resources are missing. +- In development (`NODE_ENV=development`): logs a warning but continues. +- When all resources are valid: logs a debug message with the count. + +#### Returns + +[`ValidationResult`](Interface.ValidationResult.md) + +ValidationResult with validity status, missing resources, and all resources + +#### Throws + +In production when required resources are missing + +*** + +### get() + +```ts +get(type: string, alias: string): ResourceEntry | undefined; +``` + +Gets a specific resource by type and alias. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `type` | `string` | Resource type | +| `alias` | `string` | Resource alias | + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md) \| `undefined` + +The resource entry if found, undefined otherwise + +*** + +### getAll() + +```ts +getAll(): ResourceEntry[]; +``` + +Retrieves all registered resources. +Returns a copy of the array to prevent external mutations. + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of all registered resource entries + +*** + +### getByPlugin() + +```ts +getByPlugin(pluginName: string): ResourceEntry[]; +``` + +Gets all resources required by a specific plugin. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `pluginName` | `string` | Name of the plugin | + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of resources where the plugin is listed as a requester + +*** + +### getOptional() + +```ts +getOptional(): ResourceEntry[]; +``` + +Gets all optional resources (where required=false). + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of optional resource entries + +*** + +### getRequired() + +```ts +getRequired(): ResourceEntry[]; +``` + +Gets all required resources (where required=true). + +#### Returns + +[`ResourceEntry`](Interface.ResourceEntry.md)[] + +Array of required resource entries + +*** + +### register() + +```ts +register(plugin: string, resource: ResourceRequirement): void; +``` + +Registers a resource requirement for a plugin. +If a resource with the same type+alias already exists, merges them: +- Combines plugin names (comma-separated) +- Uses the most permissive permission +- Marks as required if any plugin requires it +- Combines descriptions if they differ +- Keeps the env variable (or merges if they differ) + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `string` | Name of the plugin registering the resource | +| `resource` | [`ResourceRequirement`](Interface.ResourceRequirement.md) | Resource requirement specification | + +#### Returns + +`void` + +*** + +### size() + +```ts +size(): number; +``` + +Returns the number of registered resources. + +#### Returns + +`number` + +*** + +### validate() + +```ts +validate(): ValidationResult; +``` + +Validates all registered resources against the environment. + +Checks each resource's field environment variables to determine if it's resolved. +Updates the `resolved` and `values` fields on each resource entry. + +Only required resources affect the `valid` status - optional resources +are checked but don't cause validation failure. + +#### Returns + +[`ValidationResult`](Interface.ValidationResult.md) + +ValidationResult with validity status, missing resources, and all resources + +#### Example + +```typescript +const registry = ResourceRegistry.getInstance(); +const result = registry.validate(); + +if (!result.valid) { + console.error("Missing resources:", result.missing.map(r => Object.values(r.fields).map(f => f.env))); +} +``` + +*** + +### formatMissingResources() + +```ts +static formatMissingResources(missing: ResourceEntry[]): string; +``` + +Formats missing resources into a human-readable error message. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `missing` | [`ResourceEntry`](Interface.ResourceEntry.md)[] | Array of missing resource entries | + +#### Returns + +`string` + +Formatted error message string + +*** + +### getInstance() + +```ts +static getInstance(): ResourceRegistry; +``` + +Gets the singleton instance of the ResourceRegistry. +Creates a new instance if one doesn't exist. + +#### Returns + +`ResourceRegistry` + +*** + +### resetInstance() + +```ts +static resetInstance(): void; +``` + +Resets the singleton instance. +Primarily used for testing to ensure clean state between tests. + +#### Returns + +`void` diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md index 53241e47..bb2d12db 100644 --- a/docs/docs/api/appkit/Enumeration.ResourceType.md +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -1,33 +1,64 @@ # Enumeration: ResourceType -Supported Databricks resource types that plugins can depend on. +Supported resource types that plugins can depend on. +Each type has its own set of valid permissions. ## Enumeration Members -### JOB +### APP ```ts -JOB: "job"; +APP: "app"; ``` -Databricks Job for scheduled or triggered workflows +Databricks App dependency *** -### LAKEBASE +### DATABASE ```ts -LAKEBASE: "lakebase"; +DATABASE: "database"; ``` -Lakebase instance for persistent caching or data storage +Database (Lakebase) for persistent storage *** -### SECRET\_SCOPE +### EXPERIMENT ```ts -SECRET_SCOPE: "secret-scope"; +EXPERIMENT: "experiment"; +``` + +MLflow Experiment for ML tracking + +*** + +### GENIE\_SPACE + +```ts +GENIE_SPACE: "genie_space"; +``` + +Genie Space for AI assistant + +*** + +### JOB + +```ts +JOB: "job"; +``` + +Databricks Job for scheduled or triggered workflows + +*** + +### SECRET + +```ts +SECRET: "secret"; ``` Secret scope for secure credential storage @@ -37,7 +68,7 @@ Secret scope for secure credential storage ### SERVING\_ENDPOINT ```ts -SERVING_ENDPOINT: "serving-endpoint"; +SERVING_ENDPOINT: "serving_endpoint"; ``` Model serving endpoint for ML inference @@ -47,27 +78,47 @@ Model serving endpoint for ML inference ### SQL\_WAREHOUSE ```ts -SQL_WAREHOUSE: "sql-warehouse"; +SQL_WAREHOUSE: "sql_warehouse"; ``` Databricks SQL Warehouse for query execution *** -### UNITY\_CATALOG +### UC\_CONNECTION + +```ts +UC_CONNECTION: "uc_connection"; +``` + +Unity Catalog Connection for external data sources + +*** + +### UC\_FUNCTION ```ts -UNITY_CATALOG: "unity-catalog"; +UC_FUNCTION: "uc_function"; ``` -Unity Catalog for data governance and metadata +Unity Catalog Function *** ### VECTOR\_SEARCH\_INDEX ```ts -VECTOR_SEARCH_INDEX: "vector-search-index"; +VECTOR_SEARCH_INDEX: "vector_search_index"; +``` + +Vector Search Index for similarity search + +*** + +### VOLUME + +```ts +VOLUME: "volume"; ``` -Vector search index for similarity search +Unity Catalog Volume for file storage diff --git a/docs/docs/api/appkit/Function.getResourceRequirements.md b/docs/docs/api/appkit/Function.getResourceRequirements.md index 12ea5068..8feb69a5 100644 --- a/docs/docs/api/appkit/Function.getResourceRequirements.md +++ b/docs/docs/api/appkit/Function.getResourceRequirements.md @@ -4,9 +4,10 @@ function getResourceRequirements(plugin: PluginConstructor): { alias: string; description: string; - env?: string; + fields: Record; permission: ResourcePermission; required: boolean; + resourceKey: string; type: ResourceType; }[]; ``` diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md index 5d962219..f6559c80 100644 --- a/docs/docs/api/appkit/Interface.ResourceEntry.md +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -15,7 +15,7 @@ Extends ResourceRequirement with resolution state and plugin ownership. alias: string; ``` -Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. #### Inherited from @@ -37,18 +37,18 @@ Human-readable description of why this resource is needed *** -### env? +### fields ```ts -optional env: string; +fields: Record; ``` -Environment variable name where the resource ID/value should be provided -Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' +Map of field name to env and optional description. +Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. #### Inherited from -[`ResourceRequirement`](Interface.ResourceRequirement.md).[`env`](Interface.ResourceRequirement.md#env) +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`fields`](Interface.ResourceRequirement.md#fields) *** @@ -96,7 +96,21 @@ Whether this resource is required (true) or optional (false) resolved: boolean; ``` -Whether the resource has been resolved (environment variable found) +Whether the resource has been resolved (all field env vars set) + +*** + +### resourceKey + +```ts +resourceKey: string; +``` + +Stable key for machine use (env naming, composite keys, app.yaml). Required. + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`resourceKey`](Interface.ResourceRequirement.md#resourcekey) *** @@ -114,10 +128,10 @@ Type of Databricks resource required *** -### value? +### values? ```ts -optional value: string; +optional values: Record; ``` -The actual value of the resource (if resolved) +Resolved value per field name. Populated by validate() when all field env vars are set. diff --git a/docs/docs/api/appkit/Interface.ResourceFieldEntry.md b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md new file mode 100644 index 00000000..198334e4 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md @@ -0,0 +1,24 @@ +# Interface: ResourceFieldEntry + +Defines a single field for a resource. Each field has its own environment variable and optional description. +Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). + +## Properties + +### description? + +```ts +optional description: string; +``` + +Human-readable description for this field + +*** + +### env + +```ts +env: string; +``` + +Environment variable name for this field diff --git a/docs/docs/api/appkit/Interface.ResourceRequirement.md b/docs/docs/api/appkit/Interface.ResourceRequirement.md index 86893314..ed040a88 100644 --- a/docs/docs/api/appkit/Interface.ResourceRequirement.md +++ b/docs/docs/api/appkit/Interface.ResourceRequirement.md @@ -15,7 +15,7 @@ Can be defined statically in a manifest or dynamically via getResourceRequiremen alias: string; ``` -Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. *** @@ -29,14 +29,14 @@ Human-readable description of why this resource is needed *** -### env? +### fields ```ts -optional env: string; +fields: Record; ``` -Environment variable name where the resource ID/value should be provided -Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' +Map of field name to env and optional description. +Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. *** @@ -60,6 +60,16 @@ Whether this resource is required (true) or optional (false) *** +### resourceKey + +```ts +resourceKey: string; +``` + +Stable key for machine use (env naming, composite keys, app.yaml). Required. + +*** + ### type ```ts diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md index eb91fd57..76bc8723 100644 --- a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -1,8 +1,19 @@ # Type Alias: ResourcePermission ```ts -type ResourcePermission = "CAN_USE" | "CAN_MANAGE" | "CAN_VIEW" | "READ" | "WRITE" | "EXECUTE"; +type ResourcePermission = + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; ``` -Permission levels that can be required for a resource. -Based on Databricks permission model. +Union of all possible permission levels across all resource types. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ac912bf2..11282b99 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -7,7 +7,7 @@ plugin architecture, and React integration. | Enumeration | Description | | ------ | ------ | -| [ResourceType](Enumeration.ResourceType.md) | Supported Databricks resource types that plugins can depend on. | +| [ResourceType](Enumeration.ResourceType.md) | Supported resource types that plugins can depend on. Each type has its own set of valid permissions. | ## Classes @@ -20,6 +20,7 @@ plugin architecture, and React integration. | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | | [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | +| [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Implements singleton pattern to ensure a single source of truth. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | @@ -35,6 +36,7 @@ plugin architecture, and React integration. | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | +| [ResourceFieldEntry](Interface.ResourceFieldEntry.md) | Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). | | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Configuration for streaming execution with default and user-scoped settings | | [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications | @@ -45,7 +47,7 @@ plugin architecture, and React integration. | Type Alias | Description | | ------ | ------ | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | -| [ResourcePermission](TypeAlias.ResourcePermission.md) | Permission levels that can be required for a resource. Based on Databricks permission model. | +| [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | ## Variables diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index c310eb62..9fa0c956 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -51,6 +51,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Class.Plugin", label: "Plugin" }, + { + type: "doc", + id: "api/appkit/Class.ResourceRegistry", + label: "ResourceRegistry" + }, { type: "doc", id: "api/appkit/Class.ServerError", @@ -107,6 +112,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ResourceEntry", label: "ResourceEntry" }, + { + type: "doc", + id: "api/appkit/Interface.ResourceFieldEntry", + label: "ResourceFieldEntry" + }, { type: "doc", id: "api/appkit/Interface.ResourceRequirement", diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 1c8fee2a..16475245 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -193,7 +193,7 @@ In local development (`NODE_ENV=development`), if `asUser(req)` is called withou Configure plugins when creating your AppKit instance: ```typescript -import { createApp, server, analytics } from "@databricks/app-kit"; +import { createApp, server, analytics } from "@databricks/appkit"; const AppKit = await createApp({ plugins: [ @@ -219,7 +219,25 @@ import type express from "express"; class MyPlugin extends Plugin { name = "myPlugin"; - envVars = ["MY_API_KEY"]; + + // Define resource requirements in the static manifest + static manifest = { + name: "myPlugin", + displayName: "My Plugin", + description: "A custom plugin", + resources: { + required: [ + { + type: "secret", + alias: "apiKey", + description: "API key for external service", + permission: "READ", + env: "MY_API_KEY" + } + ], + optional: [] + } + }; async setup() { // Initialize your plugin @@ -247,10 +265,62 @@ export const myPlugin = toPlugin, "myPlug ); ``` +### Config-dependent resources + +The manifest defines resources as either `required` (always needed) or `optional` (may be needed). +For resources that become required based on plugin configuration, implement a static +`getResourceRequirements(config)` method: + +```typescript +interface MyPluginConfig extends BasePluginConfig { + enableCaching?: boolean; +} + +class MyPlugin extends Plugin { + name = "myPlugin"; + + static manifest = { + name: "myPlugin", + displayName: "My Plugin", + description: "A plugin with optional caching", + resources: { + required: [ + { type: "sql_warehouse", alias: "warehouse", description: "Query execution", permission: "CAN_USE" } + ], + optional: [ + // Listed as optional in manifest for static analysis + { type: "database", alias: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE" } + ] + } + }; + + // Runtime: Convert optional resources to required based on config + static getResourceRequirements(config: MyPluginConfig) { + const resources = []; + if (config.enableCaching) { + // When caching is enabled, Database becomes required + resources.push({ + type: "database", + alias: "cache", + description: "Query result caching", + permission: "CAN_CONNECT_AND_CREATE", + env: "DATABRICKS_DATABASE_ID", + required: true // Mark as required at runtime + }); + } + return resources; + } +} +``` + +This pattern allows: +- **Static tools** (CLI, docs) to show all possible resources +- **Runtime validation** to enforce resources based on actual configuration + ### Key extension points - **Route injection**: Implement `injectRoutes()` to add custom endpoints using [`IAppRouter`](api/appkit/TypeAlias.IAppRouter.md) -- **Lifecycle hooks**: Override `setup()`, `shutdown()`, and `validateEnv()` methods +- **Lifecycle hooks**: Override `setup()`, and `shutdown()` methods - **Shared services**: - **Cache management**: Access the cache service via `this.cache`. See [`CacheConfig`](api/appkit/Interface.CacheConfig.md) for configuration. - **Telemetry**: Instrument your plugin with traces and metrics via `this.telemetry`. See [`ITelemetry`](api/appkit/Interface.ITelemetry.md). diff --git a/docs/package.json b/docs/package.json index 658df190..78232d69 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,8 +6,9 @@ "docusaurus": "docusaurus", "dev": "pnpm run gen && docusaurus start --no-open", "build": "pnpm run gen && docusaurus build", - "gen": "pnpm run build-appkit-ui-styles && pnpm run generate-component-docs", + "gen": "pnpm run build-appkit-ui-styles && pnpm run generate-component-docs && pnpm run copy-schemas", "build-appkit-ui-styles": "tsx scripts/build-appkit-ui-styles.ts", + "copy-schemas": "tsx scripts/copy-schemas.ts", "generate-component-docs": "tsx ../tools/generate-component-mdx.ts", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", diff --git a/docs/scripts/copy-schemas.ts b/docs/scripts/copy-schemas.ts new file mode 100644 index 00000000..c519ddbd --- /dev/null +++ b/docs/scripts/copy-schemas.ts @@ -0,0 +1,47 @@ +/** + * Copies JSON schemas from packages to docs/static for hosting. + * + * Schemas are served at: + * https://databricks.github.io/appkit/schemas/{schema-name}.json + */ + +import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const SCHEMAS_SOURCE = join( + __dirname, + "../../packages/appkit/src/registry/schemas", +); +const SCHEMAS_DEST = join(__dirname, "../static/schemas"); + +function copySchemas() { + console.log("Copying JSON schemas to docs/static/schemas..."); + + // Ensure destination directory exists + if (!existsSync(SCHEMAS_DEST)) { + mkdirSync(SCHEMAS_DEST, { recursive: true }); + } + + // Check if source directory exists + if (!existsSync(SCHEMAS_SOURCE)) { + console.warn(`Schemas source directory not found: ${SCHEMAS_SOURCE}`); + return; + } + + // Copy all .json files + const files = readdirSync(SCHEMAS_SOURCE).filter((f) => f.endsWith(".json")); + + for (const file of files) { + const src = join(SCHEMAS_SOURCE, file); + const dest = join(SCHEMAS_DEST, file); + copyFileSync(src, dest); + console.log(` Copied: ${file}`); + } + + console.log(`Done! ${files.length} schema(s) copied.`); +} + +copySchemas(); diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..aa3fd137 --- /dev/null +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -0,0 +1,326 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "title": "AppKit Plugin Manifest", + "description": "Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options.", + "type": "object", + "required": ["name", "displayName", "description", "resources"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + }, + "config": { + "type": "object", + "description": "Configuration schema for the plugin", + "properties": { + "schema": { + "$ref": "#/$defs/configSchema" + } + }, + "additionalProperties": false + }, + "author": { + "type": "string", + "description": "Author name or organization" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$", + "description": "Plugin version (semver format)", + "examples": ["1.0.0", "2.1.0-beta.1"] + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source repository" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keywords for plugin discovery" + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "examples": ["Apache-2.0", "MIT"] + } + }, + "additionalProperties": false, + "$defs": { + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "secretPermission": { + "type": "string", + "enum": ["MANAGE", "READ", "WRITE"], + "description": "Permission for secret resources" + }, + "jobPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW"], + "description": "Permission for job resources" + }, + "sqlWarehousePermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_USE"], + "description": "Permission for SQL warehouse resources" + }, + "servingEndpointPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"], + "description": "Permission for serving endpoint resources" + }, + "volumePermission": { + "type": "string", + "enum": ["READ_VOLUME", "WRITE_VOLUME"], + "description": "Permission for Unity Catalog volume resources" + }, + "vectorSearchIndexPermission": { + "type": "string", + "enum": ["SELECT"], + "description": "Permission for vector search index resources" + }, + "ucFunctionPermission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Permission for Unity Catalog function resources" + }, + "ucConnectionPermission": { + "type": "string", + "enum": ["USE_CONNECTION"], + "description": "Permission for Unity Catalog connection resources" + }, + "databasePermission": { + "type": "string", + "enum": ["CAN_CONNECT_AND_CREATE"], + "description": "Permission for database resources" + }, + "genieSpacePermission": { + "type": "string", + "enum": ["CAN_EDIT", "CAN_VIEW", "CAN_RUN", "CAN_MANAGE"], + "description": "Permission for Genie Space resources" + }, + "experimentPermission": { + "type": "string", + "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + "description": "Permission for MLflow experiment resources" + }, + "appPermission": { + "type": "string", + "enum": ["CAN_USE"], + "description": "Permission for Databricks App resources" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "oneOf": [ + { "$ref": "#/$defs/secretPermission" }, + { "$ref": "#/$defs/jobPermission" }, + { "$ref": "#/$defs/sqlWarehousePermission" }, + { "$ref": "#/$defs/servingEndpointPermission" }, + { "$ref": "#/$defs/volumePermission" }, + { "$ref": "#/$defs/vectorSearchIndexPermission" }, + { "$ref": "#/$defs/ucFunctionPermission" }, + { "$ref": "#/$defs/ucConnectionPermission" }, + { "$ref": "#/$defs/databasePermission" }, + { "$ref": "#/$defs/genieSpacePermission" }, + { "$ref": "#/$defs/experimentPermission" }, + { "$ref": "#/$defs/appPermission" } + ] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + }, + "configSchemaProperty": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean", "integer"] + }, + "description": { + "type": "string" + }, + "default": {}, + "enum": { + "type": "array" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchemaProperty" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "configSchema": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean"] + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchema" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "additionalProperties": { + "type": "boolean" + } + } + } + } +} diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json new file mode 100644 index 00000000..f6bb5ef8 --- /dev/null +++ b/docs/static/schemas/template-plugins.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "title": "AppKit Template Plugins Manifest", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins and their resource requirements.", + "type": "object", + "required": ["version", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "1.0", + "description": "Schema version for the template plugins manifest" + }, + "plugins": { + "type": "object", + "description": "Map of plugin name to plugin manifest with package source", + "additionalProperties": { + "$ref": "#/$defs/templatePlugin" + } + } + }, + "additionalProperties": false, + "$defs": { + "templatePlugin": { + "type": "object", + "required": [ + "name", + "displayName", + "description", + "package", + "resources" + ], + "description": "Plugin manifest with package source information", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "package": { + "type": "string", + "minLength": 1, + "description": "NPM package name that provides this plugin", + "examples": ["@databricks/appkit", "@my-org/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init. The user will only be prompted to configure its resources. When absent or false, the plugin is optional and the user can choose whether to include it." + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index ed226b36..81a5223e 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -10,6 +10,7 @@ import type { } from "shared"; import { CacheManager } from "../cache"; import { ServiceContext } from "../context"; +import { ResourceRegistry } from "../registry"; import type { TelemetryConfig } from "../telemetry"; import { TelemetryManager } from "../telemetry"; @@ -71,8 +72,6 @@ export class AppKit { this.#pluginInstances[name] = pluginInstance; - pluginInstance.validateEnv(); - this.#setupPromises.push(pluginInstance.setup()); const self = this; @@ -154,6 +153,13 @@ export class AppKit { await ServiceContext.initialize(config?.client); const rawPlugins = config.plugins as T; + + const registry = ResourceRegistry.getInstance(); + + registry.clear(); + registry.collectResources(rawPlugins); + registry.enforceValidation(); + const preparedPlugins = AppKit.preparePlugins(rawPlugins); const mergedConfig = { plugins: preparedPlugins, diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 6b4abe0d..a50511ac 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -16,9 +16,8 @@ const createTestManifest = (name: string): PluginManifest => ({ }, }); -// Mock environment validation +// Mock utilities vi.mock("../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => ({ ...a, ...b })), })); @@ -47,17 +46,12 @@ class CoreTestPlugin implements BasePlugin { static manifest = createTestManifest("coreTest"); name = "coreTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; constructor(config: any) { this.injectedConfig = config; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -72,7 +66,6 @@ class CoreTestPlugin implements BasePlugin { return { // Expose internal state for testing setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -84,17 +77,12 @@ class NormalTestPlugin implements BasePlugin { static manifest = createTestManifest("normalTest"); name = "normalTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; constructor(config: any) { this.injectedConfig = config; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -108,7 +96,6 @@ class NormalTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -120,7 +107,6 @@ class DeferredTestPlugin implements BasePlugin { static manifest = createTestManifest("deferredTest"); name = "deferredTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; injectedPlugins: any; @@ -129,10 +115,6 @@ class DeferredTestPlugin implements BasePlugin { this.injectedPlugins = config.plugins; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -146,7 +128,6 @@ class DeferredTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, injectedPlugins: this.injectedPlugins, }; @@ -164,8 +145,6 @@ class SlowSetupPlugin implements BasePlugin { this.setupDelay = config.setupDelay || 100; } - validateEnv() {} - async setup() { await new Promise((resolve) => setTimeout(resolve, this.setupDelay)); this.setupCalled = true; @@ -189,10 +168,6 @@ class FailingPlugin implements BasePlugin { static manifest = createTestManifest("failing"); name = "failing"; - validateEnv() { - throw new Error("Environment validation failed"); - } - async setup() { throw new Error("Setup failed"); } @@ -245,7 +220,6 @@ describe("AppKit", () => { expect(instance.coreTest).toBeDefined(); // instance.coreTest returns the SDK, not the plugin instance expect(instance.coreTest.setupCalled).toBe(true); - expect(instance.coreTest.validateEnvCalled).toBe(true); }); test("should merge default and custom plugin configs", async () => { @@ -353,34 +327,8 @@ describe("AppKit", () => { expect(instance.slow2.setupCalled).toBe(true); }); - test("should validate environment for all plugins", async () => { - const pluginData = [ - { plugin: CoreTestPlugin, config: {}, name: "coreTest" }, - { plugin: NormalTestPlugin, config: {}, name: "normalTest" }, - ]; - - const instance = (await createApp({ plugins: pluginData })) as any; - - expect(instance.coreTest.validateEnvCalled).toBe(true); - expect(instance.normalTest.validateEnvCalled).toBe(true); - }); - - test("should throw error if plugin environment validation fails", async () => { - const pluginData = [ - { plugin: FailingPlugin, config: {}, name: "failing" }, - ]; - - await expect(createApp({ plugins: pluginData })).rejects.toThrow( - "Environment validation failed", - ); - }); - test("should throw error if plugin setup fails", async () => { - const FailingSetupPlugin = class extends FailingPlugin { - validateEnv() { - // Don't throw in validateEnv for this test - } - }; + const FailingSetupPlugin = class extends FailingPlugin {}; const pluginData = [ { plugin: FailingSetupPlugin, config: {}, name: "failing" }, @@ -548,7 +496,6 @@ describe("AppKit", () => { name = "contextTest"; private counter = 0; - validateEnv() {} async setup() {} injectRoutes() {} getEndpoints() { @@ -589,7 +536,6 @@ describe("AppKit", () => { name = "callbackTest"; private values: number[] = []; - validateEnv() {} async setup() {} injectRoutes() {} getEndpoints() { diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 5fe94593..d2391273 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -37,6 +37,7 @@ export type { ConfigSchemaProperty, PluginManifest, ResourceEntry, + ResourceFieldEntry, ResourcePermission, ResourceRequirement, ValidationResult, @@ -44,6 +45,7 @@ export type { export { getPluginManifest, getResourceRequirements, + ResourceRegistry, ResourceType, } from "./registry"; // Telemetry (for advanced custom telemetry) diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 4d9c168a..4f60f195 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -27,7 +27,7 @@ import { normalizeTelemetryOptions, TelemetryManager, } from "../telemetry"; -import { deepMerge, validateEnv } from "../utils"; +import { deepMerge } from "../utils"; import { DevFileReader } from "./dev-reader"; import { CacheInterceptor } from "./interceptors/cache"; import { RetryInterceptor } from "./interceptors/retry"; @@ -49,7 +49,6 @@ const EXCLUDED_FROM_PROXY = new Set([ // Lifecycle methods "setup", "shutdown", - "validateEnv", "injectRoutes", "getEndpoints", "abortActiveOperations", @@ -63,48 +62,85 @@ const EXCLUDED_FROM_PROXY = new Set([ * Base abstract class for creating AppKit plugins. * * All plugins must declare a static `manifest` property with their metadata - * and resource requirements. Plugins can also implement a static - * `getResourceRequirements()` method for dynamic requirements based on config. + * and resource requirements. The manifest defines: + * - `required` resources: Always needed for the plugin to function + * - `optional` resources: May be needed depending on plugin configuration * - * @example + * ## Static vs Runtime Resource Requirements + * + * The manifest is static and doesn't know the plugin's runtime configuration. + * For resources that become required based on config options, plugins can + * implement a static `getResourceRequirements(config)` method. + * + * At runtime, this method is called with the actual config to determine + * which "optional" resources should be treated as "required". + * + * @example Basic plugin with static requirements * ```typescript * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; * - * // Define manifest (required) * const myManifest: PluginManifest = { * name: 'myPlugin', * displayName: 'My Plugin', * description: 'Does something awesome', * resources: { * required: [ - * { - * type: ResourceType.SQL_WAREHOUSE, - * alias: 'warehouse', - * description: 'SQL Warehouse for queries', - * permission: 'CAN_USE', - * env: 'DATABRICKS_WAREHOUSE_ID' - * } + * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } * ], * optional: [] * } * }; * * class MyPlugin extends Plugin { - * static manifest = myManifest; // Required! - * + * static manifest = myManifest; * name = 'myPlugin'; - * protected envVars: string[] = []; + * } + * ``` * - * async setup() { - * // Initialize your plugin + * @example Plugin with config-dependent resources + * ```typescript + * interface MyConfig extends BasePluginConfig { + * enableCaching?: boolean; + * } + * + * const myManifest: PluginManifest = { + * name: 'myPlugin', + * resources: { + * required: [ + * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + * ], + * optional: [ + * // Database is optional in the static manifest + * { type: ResourceType.DATABASE, alias: 'cache', description: 'Required if caching enabled', ... } + * ] * } + * }; + * + * class MyPlugin extends Plugin { + * static manifest = myManifest; + * name = 'myPlugin'; * - * injectRoutes(router: Router) { - * // Register HTTP endpoints + * // Runtime method: converts optional resources to required based on config + * static getResourceRequirements(config: MyConfig) { + * const resources = []; + * if (config.enableCaching) { + * // When caching is enabled, Database becomes required + * resources.push({ + * type: ResourceType.DATABASE, + * alias: 'cache', + * resourceKey: 'database', + * description: 'Cache storage for query results', + * permission: 'CAN_CONNECT_AND_CREATE', + * fields: { + * instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' }, + * database_name: { env: 'DATABRICKS_CACHE_DB' }, + * }, + * required: true // Mark as required at runtime + * }); + * } + * return resources; * } * } - * - * export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); * ``` */ export abstract class Plugin< @@ -117,7 +153,6 @@ export abstract class Plugin< protected devFileReader: DevFileReader; protected streamManager: StreamManager; protected telemetry: ITelemetry; - protected abstract envVars: string[]; /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; @@ -146,10 +181,6 @@ export abstract class Plugin< this.isReady = true; } - validateEnv() { - validateEnv(this.envVars); - } - injectRoutes(_: express.Router) { return; } diff --git a/packages/appkit/src/plugin/tests/plugin.test.ts b/packages/appkit/src/plugin/tests/plugin.test.ts index b960a163..51f677a8 100644 --- a/packages/appkit/src/plugin/tests/plugin.test.ts +++ b/packages/appkit/src/plugin/tests/plugin.test.ts @@ -12,7 +12,6 @@ import { ServiceContext } from "../../context/service-context"; import { StreamManager } from "../../stream"; import type { ITelemetry, TelemetryProvider } from "../../telemetry"; import { TelemetryManager } from "../../telemetry"; -import { validateEnv } from "../../utils"; import type { InterceptorContext } from "../interceptors/types"; import { Plugin } from "../plugin"; @@ -25,7 +24,6 @@ vi.mock("../../cache", () => ({ })); vi.mock("../../stream"); vi.mock("../../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => { if (!a) return b; if (!b) return a; @@ -85,8 +83,6 @@ vi.mock("../interceptors/telemetry", () => ({ // Test plugin implementations class TestPlugin extends Plugin { - envVars = ["TEST_ENV_VAR"]; - async customMethod(value: string): Promise { return `processed-${value}`; } @@ -174,7 +170,6 @@ describe("Plugin", () => { vi.mocked(TelemetryManager.getProvider).mockReturnValue( mockTelemetry as TelemetryProvider, ); - vi.mocked(validateEnv).mockImplementation(() => {}); vi.clearAllMocks(); }); @@ -210,26 +205,6 @@ describe("Plugin", () => { }); }); - describe("validateEnv", () => { - test("should call validateEnv with plugin envVars", () => { - const plugin = new TestPlugin(config); - - plugin.validateEnv(); - - expect(validateEnv).toHaveBeenCalledWith(["TEST_ENV_VAR"]); - }); - - test("should propagate validation errors", () => { - vi.mocked(validateEnv).mockImplementation(() => { - throw new Error("Validation failed"); - }); - - const plugin = new TestPlugin(config); - - expect(() => plugin.validateEnv()).toThrow("Validation failed"); - }); - }); - describe("setup", () => { test("should have empty default setup", async () => { const plugin = new TestPlugin(config); diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index cc590436..1619bdf0 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -27,7 +27,6 @@ const logger = createLogger("analytics"); export class AnalyticsPlugin extends Plugin { name = "analytics"; - protected envVars: string[] = []; /** Plugin manifest declaring metadata and resource requirements */ static manifest = analyticsManifest; diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json new file mode 100644 index 00000000..7eb79313 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } + } + ], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "number", + "default": 30000, + "description": "Query execution timeout in milliseconds" + }, + "queriesDir": { + "type": "string", + "description": "Directory containing SQL query files" + }, + "cacheEnabled": { + "type": "boolean", + "default": true, + "description": "Enable query result caching" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/analytics/manifest.ts b/packages/appkit/src/plugins/analytics/manifest.ts index bc431b93..fe74e345 100644 --- a/packages/appkit/src/plugins/analytics/manifest.ts +++ b/packages/appkit/src/plugins/analytics/manifest.ts @@ -1,49 +1,20 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginManifest } from "../../registry"; -import { ResourceType } from "../../registry"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); /** * Analytics plugin manifest. * * The analytics plugin requires a SQL Warehouse for executing queries * against Databricks data sources. + * + * @remarks + * The source of truth for this manifest is `manifest.json` in the same directory. + * This file loads the JSON and exports it with proper TypeScript typing. */ -export const analyticsManifest: PluginManifest = { - name: "analytics", - displayName: "Analytics Plugin", - description: "SQL query execution against Databricks SQL Warehouses", - - resources: { - required: [ - { - type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", - description: "SQL Warehouse for executing analytics queries", - permission: "CAN_USE", - env: "DATABRICKS_WAREHOUSE_ID", - }, - ], - optional: [], - }, - - config: { - schema: { - type: "object", - properties: { - timeout: { - type: "number", - default: 30000, - description: "Query execution timeout in milliseconds", - }, - queriesDir: { - type: "string", - description: "Directory containing SQL query files", - }, - cacheEnabled: { - type: "boolean", - default: true, - description: "Enable query result caching", - }, - }, - }, - }, -}; +export const analyticsManifest: PluginManifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 61228f35..40cf01e0 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -44,7 +44,6 @@ export class ServerPlugin extends Plugin { static manifest = serverManifest; public name = "server" as const; - protected envVars: string[] = []; private serverApplication: express.Application; private server: HTTPServer | null; private viteDevServer?: ViteDevServer; diff --git a/packages/appkit/src/plugins/server/manifest.json b/packages/appkit/src/plugins/server/manifest.json new file mode 100644 index 00000000..11822beb --- /dev/null +++ b/packages/appkit/src/plugins/server/manifest.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "resources": { + "required": [], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "autoStart": { + "type": "boolean", + "default": true, + "description": "Automatically start the server on plugin setup" + }, + "host": { + "type": "string", + "default": "0.0.0.0", + "description": "Host address to bind the server to" + }, + "port": { + "type": "number", + "default": 8000, + "description": "Port number for the server" + }, + "staticPath": { + "type": "string", + "description": "Path to static files directory (auto-detected if not provided)" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/server/manifest.ts b/packages/appkit/src/plugins/server/manifest.ts index 0973230e..97a4c716 100644 --- a/packages/appkit/src/plugins/server/manifest.ts +++ b/packages/appkit/src/plugins/server/manifest.ts @@ -1,47 +1,20 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { PluginManifest } from "../../registry"; +const __dirname = dirname(fileURLToPath(import.meta.url)); + /** * Server plugin manifest. * * The server plugin doesn't require any Databricks resources - it only * provides HTTP server functionality and static file serving. + * + * @remarks + * The source of truth for this manifest is `manifest.json` in the same directory. + * This file loads the JSON and exports it with proper TypeScript typing. */ -export const serverManifest: PluginManifest = { - name: "server", - displayName: "Server Plugin", - description: - "HTTP server with Express, static file serving, and Vite dev mode support", - - resources: { - required: [], - optional: [], - }, - - config: { - schema: { - type: "object", - properties: { - autoStart: { - type: "boolean", - default: true, - description: "Automatically start the server on plugin setup", - }, - host: { - type: "string", - default: "0.0.0.0", - description: "Host address to bind the server to", - }, - port: { - type: "number", - default: 8000, - description: "Port number for the server", - }, - staticPath: { - type: "string", - description: - "Path to static files directory (auto-detected if not provided)", - }, - }, - }, - }, -}; +export const serverManifest: PluginManifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index ded42c84..84496348 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -105,7 +105,6 @@ describe("ServerPlugin with custom plugin", () => { resources: { required: [], optional: [] }, }; name = "test-plugin" as const; - envVars: string[] = []; injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index a1521d1e..31305fc7 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -91,7 +91,6 @@ vi.mock("../../../cache", () => ({ })); vi.mock("../../../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => ({ ...a, ...b })), })); @@ -143,13 +142,17 @@ vi.mock("dotenv", () => ({ default: { config: vi.fn() }, })); -// Mock fs for findStaticPath -vi.mock("node:fs", () => ({ - default: { - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn(), - }, -})); +// Mock fs for findStaticPath and manifest loading +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + existsSync: vi.fn().mockReturnValue(false), + readFileSync: actual.readFileSync, + }, + }; +}); vi.mock("../utils", () => ({ getRoutes: vi.fn().mockReturnValue([]), diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index d5c0c07b..bc543027 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -7,9 +7,30 @@ * Components: * - Type definitions for resources, manifests, and validation * - Manifest loader for reading plugin declarations - * - (Future) ResourceRegistry singleton for tracking requirements + * - ResourceRegistry singleton for tracking requirements across all plugins + * - JSON Schema for validating plugin manifests * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; +export { ResourceRegistry } from "./resource-registry"; export * from "./types"; + +/** + * URL to the plugin manifest JSON Schema hosted on GitHub Pages. + * Can be used for validation or referenced in manifest files. + * + * @example + * ```json + * { + * "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + * "name": "my-plugin", + * ... + * } + * ``` + */ +// TODO: We may want to open a PR to https://github.com/SchemaStore/schemastore +// export const MANIFEST_SCHEMA_ID = +// "https://json.schemastore.org/databricks-appkit-plugin-manifest.json"; +export const MANIFEST_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts new file mode 100644 index 00000000..459bdafb --- /dev/null +++ b/packages/appkit/src/registry/resource-registry.ts @@ -0,0 +1,427 @@ +/** + * Resource Registry Singleton + * + * Central registry that tracks all resource requirements across all plugins. + * Provides global visibility into Databricks resources needed by the application + * and handles deduplication when multiple plugins require the same resource. + */ + +import type { BasePluginConfig, PluginConstructor, PluginData } from "shared"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging/logger"; +import { getPluginManifest } from "./manifest-loader"; +import type { + ResourceEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./types"; + +const logger = createLogger("resource-registry"); + +/** + * Permission hierarchy for merging logic. + * Higher index = more permissive. + */ +const PERMISSION_HIERARCHY: ResourcePermission[] = [ + "CAN_VIEW", + "READ", + "CAN_USE", + "WRITE", + "EXECUTE", + "CAN_MANAGE", +]; + +/** + * Returns the most permissive permission between two permissions. + */ +function getMostPermissivePermission( + p1: ResourcePermission, + p2: ResourcePermission, +): ResourcePermission { + const index1 = PERMISSION_HIERARCHY.indexOf(p1); + const index2 = PERMISSION_HIERARCHY.indexOf(p2); + return index1 > index2 ? p1 : p2; +} + +/** + * Generates a unique key for a resource based on type and alias. + */ +function getResourceKey(type: string, alias: string): string { + return `${type}:${alias}`; +} + +/** + * Central registry for tracking plugin resource requirements. + * Implements singleton pattern to ensure a single source of truth. + */ +export class ResourceRegistry { + private static instance: ResourceRegistry | null = null; + private resources: Map = new Map(); + + /** + * Private constructor to enforce singleton pattern. + */ + private constructor() {} + + /** + * Gets the singleton instance of the ResourceRegistry. + * Creates a new instance if one doesn't exist. + */ + public static getInstance(): ResourceRegistry { + if (!ResourceRegistry.instance) { + ResourceRegistry.instance = new ResourceRegistry(); + } + return ResourceRegistry.instance; + } + + /** + * Resets the singleton instance. + * Primarily used for testing to ensure clean state between tests. + */ + public static resetInstance(): void { + ResourceRegistry.instance = null; + } + + /** + * Registers a resource requirement for a plugin. + * If a resource with the same type+alias already exists, merges them: + * - Combines plugin names (comma-separated) + * - Uses the most permissive permission + * - Marks as required if any plugin requires it + * - Combines descriptions if they differ + * - Keeps the env variable (or merges if they differ) + * + * @param plugin - Name of the plugin registering the resource + * @param resource - Resource requirement specification + */ + public register(plugin: string, resource: ResourceRequirement): void { + const key = getResourceKey(resource.type, resource.alias); + const existing = this.resources.get(key); + + if (existing) { + // Merge with existing resource + const merged = this.mergeResources(existing, plugin, resource); + this.resources.set(key, merged); + } else { + // Create new resource entry + const entry: ResourceEntry = { + ...resource, + plugin, + resolved: false, + }; + this.resources.set(key, entry); + } + } + + /** + * Collects and registers resource requirements from an array of plugins. + * For each plugin, loads its manifest to discover static resource declarations, + * then checks for runtime resource requirements via `getResourceRequirements()`. + * + * Plugins without manifests are silently skipped (allowed for legacy plugins + * or plugins that don't declare resources). + * + * @param rawPlugins - Array of plugin data entries from createApp configuration + */ + public collectResources( + rawPlugins: PluginData[], + ): void { + for (const pluginData of rawPlugins) { + if (!pluginData?.plugin) continue; + + const pluginName = pluginData.name; + + try { + const manifest = getPluginManifest(pluginData.plugin); + + // Register required resources + for (const resource of manifest.resources.required) { + this.register(pluginName, { ...resource, required: true }); + } + + // Register optional resources + for (const resource of manifest.resources.optional || []) { + this.register(pluginName, { ...resource, required: false }); + } + + // Check for runtime resource requirements + if (typeof pluginData.plugin.getResourceRequirements === "function") { + const runtimeResources = pluginData.plugin.getResourceRequirements( + pluginData.config as BasePluginConfig, + ); + for (const resource of runtimeResources) { + // Cast from shared's ResourceRequirement to registry's ResourceRequirement + // The shared type has looser typing (string) vs registry (ResourceType enum) + this.register(pluginName, resource as ResourceRequirement); + } + } + + logger.debug( + "Collected resources from plugin %s: %d total", + pluginName, + this.getByPlugin(pluginName).length, + ); + } catch (error) { + // Plugin doesn't have a manifest - this is allowed for legacy plugins + // or plugins that don't declare resources + logger.debug( + "Plugin %s has no manifest or invalid manifest: %s", + pluginName, + error instanceof Error ? error.message : String(error), + ); + } + } + } + + /** + * Merges a new resource requirement with an existing entry. + * Applies intelligent merging logic for conflicting properties. + */ + private mergeResources( + existing: ResourceEntry, + newPlugin: string, + newResource: ResourceRequirement, + ): ResourceEntry { + // Combine plugin names if not already included + const plugins = existing.plugin.split(", "); + if (!plugins.includes(newPlugin)) { + plugins.push(newPlugin); + } + + // Use the most permissive permission + const permission = getMostPermissivePermission( + existing.permission, + newResource.permission, + ); + + // Mark as required if any plugin requires it + const required = existing.required || newResource.required; + + // Combine descriptions if they differ + let description = existing.description; + if ( + newResource.description && + newResource.description !== existing.description + ) { + // Check if the new description is already included + if (!existing.description.includes(newResource.description)) { + description = `${existing.description}; ${newResource.description}`; + } + } + + // Prefer existing fields when both have them (same type+alias) + const fields = existing.fields ?? newResource.fields; + + return { + ...existing, + plugin: plugins.join(", "), + permission, + required, + description, + fields, + }; + } + + /** + * Retrieves all registered resources. + * Returns a copy of the array to prevent external mutations. + * + * @returns Array of all registered resource entries + */ + public getAll(): ResourceEntry[] { + return Array.from(this.resources.values()); + } + + /** + * Gets a specific resource by type and alias. + * + * @param type - Resource type + * @param alias - Resource alias + * @returns The resource entry if found, undefined otherwise + */ + public get(type: string, alias: string): ResourceEntry | undefined { + const key = getResourceKey(type, alias); + return this.resources.get(key); + } + + /** + * Clears all registered resources. + * Useful for testing or when rebuilding the registry. + */ + public clear(): void { + this.resources.clear(); + } + + /** + * Returns the number of registered resources. + */ + public size(): number { + return this.resources.size; + } + + /** + * Gets all resources required by a specific plugin. + * + * @param pluginName - Name of the plugin + * @returns Array of resources where the plugin is listed as a requester + */ + public getByPlugin(pluginName: string): ResourceEntry[] { + return this.getAll().filter((entry) => + entry.plugin.split(", ").includes(pluginName), + ); + } + + /** + * Gets all required resources (where required=true). + * + * @returns Array of required resource entries + */ + public getRequired(): ResourceEntry[] { + return this.getAll().filter((entry) => entry.required); + } + + /** + * Gets all optional resources (where required=false). + * + * @returns Array of optional resource entries + */ + public getOptional(): ResourceEntry[] { + return this.getAll().filter((entry) => !entry.required); + } + + /** + * Validates all registered resources against the environment. + * + * Checks each resource's field environment variables to determine if it's resolved. + * Updates the `resolved` and `values` fields on each resource entry. + * + * Only required resources affect the `valid` status - optional resources + * are checked but don't cause validation failure. + * + * @returns ValidationResult with validity status, missing resources, and all resources + * + * @example + * ```typescript + * const registry = ResourceRegistry.getInstance(); + * const result = registry.validate(); + * + * if (!result.valid) { + * console.error("Missing resources:", result.missing.map(r => Object.values(r.fields).map(f => f.env))); + * } + * ``` + */ + public validate(): ValidationResult { + const missing: ResourceEntry[] = []; + + for (const entry of this.resources.values()) { + const values: Record = {}; + let allSet = true; + for (const [fieldName, fieldDef] of Object.entries(entry.fields)) { + const val = process.env[fieldDef.env]; + if (val !== undefined && val !== "") { + values[fieldName] = val; + } else { + allSet = false; + } + } + if (allSet) { + entry.resolved = true; + entry.values = values; + logger.debug( + "Resource %s:%s resolved from fields", + entry.type, + entry.alias, + ); + } else { + entry.resolved = false; + entry.values = Object.keys(values).length > 0 ? values : undefined; + if (entry.required) { + missing.push(entry); + logger.debug( + "Required resource %s:%s missing (fields: %s)", + entry.type, + entry.alias, + Object.keys(entry.fields).join(", "), + ); + } else { + logger.debug( + "Optional resource %s:%s not configured (fields: %s)", + entry.type, + entry.alias, + Object.keys(entry.fields).join(", "), + ); + } + } + } + + return { + valid: missing.length === 0, + missing, + all: this.getAll(), + }; + } + + /** + * Validates all registered resources and enforces the result. + * + * - In production: throws a {@link ConfigurationError} if any required resources are missing. + * - In development (`NODE_ENV=development`): logs a warning but continues. + * - When all resources are valid: logs a debug message with the count. + * + * @returns ValidationResult with validity status, missing resources, and all resources + * @throws {ConfigurationError} In production when required resources are missing + */ + public enforceValidation(): ValidationResult { + const validation = this.validate(); + const isDevelopment = process.env.NODE_ENV === "development"; + + if (!validation.valid) { + const errorMessage = ResourceRegistry.formatMissingResources( + validation.missing, + ); + + if (isDevelopment) { + logger.warn( + "Missing resources detected (continuing in dev mode):\n%s", + errorMessage, + ); + } else { + throw new ConfigurationError(errorMessage, { + context: { + missingResources: validation.missing.map((r) => ({ + type: r.type, + alias: r.alias, + plugin: r.plugin, + envVars: Object.values(r.fields).map((f) => f.env), + })), + }, + }); + } + } else if (this.size() > 0) { + logger.debug("All %d resources validated successfully", this.size()); + } + + return validation; + } + + /** + * Formats missing resources into a human-readable error message. + * + * @param missing - Array of missing resource entries + * @returns Formatted error message string + */ + public static formatMissingResources(missing: ResourceEntry[]): string { + if (missing.length === 0) { + return "No missing resources"; + } + + const lines = missing.map((entry) => { + const envVars = Object.values(entry.fields).map((f) => f.env); + const envHint = ` (set ${envVars.join(", ")})`; + return ` - ${entry.type}:${entry.alias} [${entry.plugin}]${envHint}`; + }); + + return `Missing required resources:\n${lines.join("\n")}`; + } +} diff --git a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..aa3fd137 --- /dev/null +++ b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json @@ -0,0 +1,326 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "title": "AppKit Plugin Manifest", + "description": "Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options.", + "type": "object", + "required": ["name", "displayName", "description", "resources"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + }, + "config": { + "type": "object", + "description": "Configuration schema for the plugin", + "properties": { + "schema": { + "$ref": "#/$defs/configSchema" + } + }, + "additionalProperties": false + }, + "author": { + "type": "string", + "description": "Author name or organization" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$", + "description": "Plugin version (semver format)", + "examples": ["1.0.0", "2.1.0-beta.1"] + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source repository" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Keywords for plugin discovery" + }, + "license": { + "type": "string", + "description": "SPDX license identifier", + "examples": ["Apache-2.0", "MIT"] + } + }, + "additionalProperties": false, + "$defs": { + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "secretPermission": { + "type": "string", + "enum": ["MANAGE", "READ", "WRITE"], + "description": "Permission for secret resources" + }, + "jobPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_MANAGE_RUN", "CAN_VIEW"], + "description": "Permission for job resources" + }, + "sqlWarehousePermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_USE"], + "description": "Permission for SQL warehouse resources" + }, + "servingEndpointPermission": { + "type": "string", + "enum": ["CAN_MANAGE", "CAN_QUERY", "CAN_VIEW"], + "description": "Permission for serving endpoint resources" + }, + "volumePermission": { + "type": "string", + "enum": ["READ_VOLUME", "WRITE_VOLUME"], + "description": "Permission for Unity Catalog volume resources" + }, + "vectorSearchIndexPermission": { + "type": "string", + "enum": ["SELECT"], + "description": "Permission for vector search index resources" + }, + "ucFunctionPermission": { + "type": "string", + "enum": ["EXECUTE"], + "description": "Permission for Unity Catalog function resources" + }, + "ucConnectionPermission": { + "type": "string", + "enum": ["USE_CONNECTION"], + "description": "Permission for Unity Catalog connection resources" + }, + "databasePermission": { + "type": "string", + "enum": ["CAN_CONNECT_AND_CREATE"], + "description": "Permission for database resources" + }, + "genieSpacePermission": { + "type": "string", + "enum": ["CAN_EDIT", "CAN_VIEW", "CAN_RUN", "CAN_MANAGE"], + "description": "Permission for Genie Space resources" + }, + "experimentPermission": { + "type": "string", + "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + "description": "Permission for MLflow experiment resources" + }, + "appPermission": { + "type": "string", + "enum": ["CAN_USE"], + "description": "Permission for Databricks App resources" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "oneOf": [ + { "$ref": "#/$defs/secretPermission" }, + { "$ref": "#/$defs/jobPermission" }, + { "$ref": "#/$defs/sqlWarehousePermission" }, + { "$ref": "#/$defs/servingEndpointPermission" }, + { "$ref": "#/$defs/volumePermission" }, + { "$ref": "#/$defs/vectorSearchIndexPermission" }, + { "$ref": "#/$defs/ucFunctionPermission" }, + { "$ref": "#/$defs/ucConnectionPermission" }, + { "$ref": "#/$defs/databasePermission" }, + { "$ref": "#/$defs/genieSpacePermission" }, + { "$ref": "#/$defs/experimentPermission" }, + { "$ref": "#/$defs/appPermission" } + ] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + }, + "configSchemaProperty": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean", "integer"] + }, + "description": { + "type": "string" + }, + "default": {}, + "enum": { + "type": "array" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchemaProperty" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "configSchema": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["object", "array", "string", "number", "boolean"] + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/configSchemaProperty" + } + }, + "items": { + "$ref": "#/$defs/configSchema" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "additionalProperties": { + "type": "boolean" + } + } + } + } +} diff --git a/packages/appkit/src/registry/schemas/template-plugins.schema.json b/packages/appkit/src/registry/schemas/template-plugins.schema.json new file mode 100644 index 00000000..f6bb5ef8 --- /dev/null +++ b/packages/appkit/src/registry/schemas/template-plugins.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "title": "AppKit Template Plugins Manifest", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins and their resource requirements.", + "type": "object", + "required": ["version", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "1.0", + "description": "Schema version for the template plugins manifest" + }, + "plugins": { + "type": "object", + "description": "Map of plugin name to plugin manifest with package source", + "additionalProperties": { + "$ref": "#/$defs/templatePlugin" + } + } + }, + "additionalProperties": false, + "$defs": { + "templatePlugin": { + "type": "object", + "required": [ + "name", + "displayName", + "description", + "package", + "resources" + ], + "description": "Plugin manifest with package source information", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "package": { + "type": "string", + "minLength": 1, + "description": "NPM package name that provides this plugin", + "examples": ["@databricks/appkit", "@my-org/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init. The user will only be prompted to configure its resources. When absent or false, the plugin is optional and the user can choose whether to include it." + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts index ce587758..6b328af2 100644 --- a/packages/appkit/src/registry/tests/integration.test.ts +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -27,15 +27,19 @@ describe("Manifest Loader Integration", () => { expect(manifest?.displayName).toBe("Analytics Plugin"); }); - it("should require SQL Warehouse", () => { + it("should require SQL Warehouse and list optional cache database", () => { const resources = getResourceRequirements(AnalyticsPlugin); expect(resources).toHaveLength(1); - expect(resources[0]).toMatchObject({ + + const required = resources.find((r) => r.required); + expect(required).toBeDefined(); + + expect(required).toMatchObject({ type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", + resourceKey: "sql-warehouse", required: true, permission: "CAN_USE", - env: "DATABRICKS_WAREHOUSE_ID", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, }); }); diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts index 0578b9dc..4e18170e 100644 --- a/packages/appkit/src/registry/tests/manifest-loader.test.ts +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -21,9 +21,12 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "sql-warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { + id: { env: "TEST_WAREHOUSE_ID", description: "Warehouse ID" }, + }, }, ], optional: [], @@ -195,11 +198,15 @@ describe("Manifest Loader", () => { required: [], optional: [ { - type: ResourceType.SECRET_SCOPE, - alias: "secrets", + type: ResourceType.SECRET, + alias: "Secret", + resourceKey: "secret", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -237,9 +244,10 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, }, ], optional: [], @@ -270,11 +278,15 @@ describe("Manifest Loader", () => { required: [], optional: [ { - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", + resourceKey: "secrets", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -289,7 +301,7 @@ describe("Manifest Loader", () => { ); expect(resources).toHaveLength(1); expect(resources[0]).toMatchObject({ - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", required: false, }); @@ -305,18 +317,23 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, }, ], optional: [ { - type: ResourceType.SECRET_SCOPE, + type: ResourceType.SECRET, alias: "secrets", + resourceKey: "secrets", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -334,6 +351,60 @@ describe("Manifest Loader", () => { expect(resources[1].required).toBe(false); }); + it("should return resources with fields for multi-field types", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Database for caching", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.DATABASE, + alias: "cache", + required: true, + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }); + }); + it("should return empty array for plugin with no resources", () => { const mockManifest: PluginManifest = { name: "test-plugin", diff --git a/packages/appkit/src/registry/tests/resource-registry.test.ts b/packages/appkit/src/registry/tests/resource-registry.test.ts new file mode 100644 index 00000000..1abf5853 --- /dev/null +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ResourceRegistry } from "../resource-registry"; +import { ResourceType } from "../types"; + +describe("ResourceRegistry", () => { + beforeEach(() => { + ResourceRegistry.resetInstance(); + }); + + afterEach(() => { + ResourceRegistry.resetInstance(); + }); + + describe("register and merge with fields", () => { + it("should register a multi-field resource (database)", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Database for caching", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }); + + const entry = registry.get("database", "cache"); + expect(entry).toBeDefined(); + expect(entry?.fields).toEqual({ + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }); + }); + + it("should merge resources and prefer existing fields", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE_A", description: "Scope" }, + key: { env: "SECRET_KEY_A", description: "Key" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: false, + fields: { + scope: { env: "SECRET_SCOPE_B", description: "Scope" }, + key: { env: "SECRET_KEY_B", description: "Key" }, + }, + }); + + const entry = registry.get("secret", "creds"); + expect(entry?.fields).toEqual({ + scope: { env: "SECRET_SCOPE_A", description: "Scope" }, + key: { env: "SECRET_KEY_A", description: "Key" }, + }); + expect(entry?.plugin).toContain("plugin-a"); + expect(entry?.plugin).toContain("plugin-b"); + }); + + it("should merge single-value resources (fields with one key)", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: false, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.fields).toEqual({ + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }); + }); + }); + + describe("validate with fields", () => { + const CACHE_INSTANCE = "DATABRICKS_CACHE_INSTANCE"; + const CACHE_DB = "DATABRICKS_CACHE_DB"; + + it("should resolve multi-field resource when all env vars are set", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + const orig = process.env[CACHE_INSTANCE]; + const origDb = process.env[CACHE_DB]; + process.env[CACHE_INSTANCE] = "my-instance"; + process.env[CACHE_DB] = "my_db"; + try { + const result = registry.validate(); + expect(result.valid).toBe(true); + expect(result.missing).toHaveLength(0); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(true); + expect(entry?.values).toEqual({ + instance_name: "my-instance", + database_name: "my_db", + }); + } finally { + if (orig !== undefined) process.env[CACHE_INSTANCE] = orig; + else delete process.env[CACHE_INSTANCE]; + if (origDb !== undefined) process.env[CACHE_DB] = origDb; + else delete process.env[CACHE_DB]; + } + }); + + it("should mark multi-field resource missing when any env var is unset", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + delete process.env[CACHE_INSTANCE]; + delete process.env[CACHE_DB]; + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + expect(result.missing[0].type).toBe("database"); + expect(result.missing[0].alias).toBe("cache"); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toBeUndefined(); + }); + + it("should mark multi-field resource missing when only one env var is set", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + process.env[CACHE_INSTANCE] = "my-instance"; + delete process.env[CACHE_DB]; + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toEqual({ instance_name: "my-instance" }); + }); + }); + + describe("formatMissingResources with fields", () => { + it("should list field env vars for multi-field missing resources", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE" }, + key: { env: "SECRET_KEY" }, + }, + }); + + delete process.env.SECRET_SCOPE; + delete process.env.SECRET_KEY; + const result = registry.validate(); + expect(result.valid).toBe(false); + + const formatted = ResourceRegistry.formatMissingResources(result.missing); + expect(formatted).toContain("secret:creds"); + expect(formatted).toContain("SECRET_SCOPE"); + expect(formatted).toContain("SECRET_KEY"); + }); + + it("should list field env vars for single-value missing resources", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + delete process.env.DATABRICKS_WAREHOUSE_ID; + const result = registry.validate(); + const formatted = ResourceRegistry.formatMissingResources(result.missing); + expect(formatted).toContain("DATABRICKS_WAREHOUSE_ID"); + }); + }); +}); diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index 18216521..c9716fb5 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -4,45 +4,125 @@ * This module defines the type system for the AppKit Resource Registry, * which enables plugins to declare their Databricks resource requirements * in a machine-readable format. + * + * Resource types are exposed as first-class citizens with their specific + * permissions, making it simple for users to declare dependencies. + * Internal tooling handles conversion to Databricks app.yaml format. */ /** - * Supported Databricks resource types that plugins can depend on. + * Supported resource types that plugins can depend on. + * Each type has its own set of valid permissions. */ export enum ResourceType { - /** Databricks SQL Warehouse for query execution */ - SQL_WAREHOUSE = "sql-warehouse", - - /** Lakebase instance for persistent caching or data storage */ - LAKEBASE = "lakebase", + /** Secret scope for secure credential storage */ + SECRET = "secret", /** Databricks Job for scheduled or triggered workflows */ JOB = "job", - /** Secret scope for secure credential storage */ - SECRET_SCOPE = "secret-scope", + /** Databricks SQL Warehouse for query execution */ + SQL_WAREHOUSE = "sql_warehouse", /** Model serving endpoint for ML inference */ - SERVING_ENDPOINT = "serving-endpoint", + SERVING_ENDPOINT = "serving_endpoint", + + /** Unity Catalog Volume for file storage */ + VOLUME = "volume", + + /** Vector Search Index for similarity search */ + VECTOR_SEARCH_INDEX = "vector_search_index", + + /** Unity Catalog Function */ + UC_FUNCTION = "uc_function", - /** Vector search index for similarity search */ - VECTOR_SEARCH_INDEX = "vector-search-index", + /** Unity Catalog Connection for external data sources */ + UC_CONNECTION = "uc_connection", - /** Unity Catalog for data governance and metadata */ - UNITY_CATALOG = "unity-catalog", + /** Database (Lakebase) for persistent storage */ + DATABASE = "database", + + /** Genie Space for AI assistant */ + GENIE_SPACE = "genie_space", + + /** MLflow Experiment for ML tracking */ + EXPERIMENT = "experiment", + + /** Databricks App dependency */ + APP = "app", } +// ============================================================================ +// Permissions per resource type +// ============================================================================ + +/** Permissions for SECRET resources */ +export type SecretPermission = "MANAGE" | "READ" | "WRITE"; + +/** Permissions for JOB resources */ +export type JobPermission = "CAN_MANAGE" | "CAN_MANAGE_RUN" | "CAN_VIEW"; + +/** Permissions for SQL_WAREHOUSE resources */ +export type SqlWarehousePermission = "CAN_MANAGE" | "CAN_USE"; + +/** Permissions for SERVING_ENDPOINT resources */ +export type ServingEndpointPermission = "CAN_MANAGE" | "CAN_QUERY" | "CAN_VIEW"; + +/** Permissions for VOLUME resources */ +export type VolumePermission = "READ_VOLUME" | "WRITE_VOLUME"; + +/** Permissions for VECTOR_SEARCH_INDEX resources */ +export type VectorSearchIndexPermission = "SELECT"; + +/** Permissions for UC_FUNCTION resources */ +export type UcFunctionPermission = "EXECUTE"; + +/** Permissions for UC_CONNECTION resources */ +export type UcConnectionPermission = "USE_CONNECTION"; + +/** Permissions for DATABASE resources */ +export type DatabasePermission = "CAN_CONNECT_AND_CREATE"; + +/** Permissions for GENIE_SPACE resources */ +export type GenieSpacePermission = + | "CAN_EDIT" + | "CAN_VIEW" + | "CAN_RUN" + | "CAN_MANAGE"; + +/** Permissions for EXPERIMENT resources */ +export type ExperimentPermission = "CAN_READ" | "CAN_EDIT" | "CAN_MANAGE"; + +/** Permissions for APP resources */ +export type AppPermission = "CAN_USE"; + /** - * Permission levels that can be required for a resource. - * Based on Databricks permission model. + * Union of all possible permission levels across all resource types. */ export type ResourcePermission = - | "CAN_USE" - | "CAN_MANAGE" - | "CAN_VIEW" - | "READ" - | "WRITE" - | "EXECUTE"; + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; + +/** + * Defines a single field for a resource. Each field has its own environment variable and optional description. + * Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). + */ +export interface ResourceFieldEntry { + /** Environment variable name for this field */ + env: string; + /** Human-readable description for this field */ + description?: string; +} /** * Declares a resource requirement for a plugin. @@ -52,9 +132,12 @@ export interface ResourceRequirement { /** Type of Databricks resource required */ type: ResourceType; - /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') */ + /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. */ alias: string; + /** Stable key for machine use (env naming, composite keys, app.yaml). Required. */ + resourceKey: string; + /** Human-readable description of why this resource is needed */ description: string; @@ -62,10 +145,10 @@ export interface ResourceRequirement { permission: ResourcePermission; /** - * Environment variable name where the resource ID/value should be provided - * Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + * Map of field name to env and optional description. + * Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. */ - env?: string; + fields: Record; /** Whether this resource is required (true) or optional (false) */ required: boolean; @@ -79,11 +162,11 @@ export interface ResourceEntry extends ResourceRequirement { /** Plugin(s) that require this resource (comma-separated if multiple) */ plugin: string; - /** Whether the resource has been resolved (environment variable found) */ + /** Whether the resource has been resolved (all field env vars set) */ resolved: boolean; - /** The actual value of the resource (if resolved) */ - value?: string; + /** Resolved value per field name. Populated by validate() when all field env vars are set. */ + values?: Record; } /** diff --git a/packages/appkit/src/utils/env-validator.ts b/packages/appkit/src/utils/env-validator.ts deleted file mode 100644 index adc35a22..00000000 --- a/packages/appkit/src/utils/env-validator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ValidationError } from "../errors"; - -export function validateEnv(envVars: string[]) { - const missingVars = []; - - for (const envVar of envVars) { - if (!process.env[envVar]) { - missingVars.push(envVar); - } - } - - if (missingVars.length > 0) { - throw ValidationError.missingEnvVars(missingVars); - } -} diff --git a/packages/appkit/src/utils/index.ts b/packages/appkit/src/utils/index.ts index 23770d21..c0b1b55b 100644 --- a/packages/appkit/src/utils/index.ts +++ b/packages/appkit/src/utils/index.ts @@ -1,4 +1,3 @@ -export * from "./env-validator"; export * from "./merge"; export * from "./path-exclusions"; export * from "./vite-config-merge"; diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 414efbb2..ad8c46be 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -37,6 +37,25 @@ export default defineConfig([ from: "src/plugins/server/remote-tunnel/denied.html", to: "dist/plugins/server/remote-tunnel/denied.html", }, + // Plugin manifest JSON files (source of truth for static analysis) + { + from: "src/plugins/analytics/manifest.json", + to: "dist/plugins/analytics/manifest.json", + }, + { + from: "src/plugins/server/manifest.json", + to: "dist/plugins/server/manifest.json", + }, + // JSON Schema for plugin manifests + { + from: "src/registry/schemas/plugin-manifest.schema.json", + to: "dist/registry/schemas/plugin-manifest.schema.json", + }, + // JSON Schema for template plugins manifest + { + from: "src/registry/schemas/template-plugins.schema.json", + to: "dist/registry/schemas/template-plugins.schema.json", + }, ], }, ]); diff --git a/packages/shared/bin/appkit.js b/packages/shared/bin/appkit.js old mode 100644 new mode 100755 diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugins-sync.ts new file mode 100644 index 00000000..7b0cad3d --- /dev/null +++ b/packages/shared/src/cli/commands/plugins-sync.ts @@ -0,0 +1,505 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Lang, parse, type SgNode } from "@ast-grep/napi"; +import { Command } from "commander"; + +/** + * Field entry in a resource requirement (env var + optional description) + */ +interface ResourceFieldEntry { + env: string; + description?: string; +} + +/** + * Resource requirement as defined in plugin manifests. + * Uses fields (single key e.g. id, or multiple e.g. instance_name/database_name, scope/key). + */ +interface ResourceRequirement { + type: string; + alias: string; + resourceKey: string; + description: string; + permission: string; + fields: Record; +} + +/** + * Plugin manifest structure (from SDK plugin manifest.json files) + */ +interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: ResourceRequirement[]; + optional: ResourceRequirement[]; + }; + config?: { schema: unknown }; +} + +/** + * Plugin entry in the template manifest (includes package source) + */ +interface TemplatePlugin extends Omit { + package: string; + /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ + requiredByTemplate?: boolean; +} + +/** + * Template plugins manifest structure + */ +interface TemplatePluginsManifest { + $schema: string; + version: string; + plugins: Record; +} + +/** + * Known packages that may contain AppKit plugins. + * Always scanned for manifests, even if not imported in the server file. + */ +const KNOWN_PLUGIN_PACKAGES = ["@databricks/appkit"]; + +/** + * Candidate paths for the server entry file, relative to cwd. + * Checked in order; the first that exists is used. + */ +const SERVER_FILE_CANDIDATES = ["server/server.ts"]; + +/** + * Find the server entry file by checking candidate paths in order. + * + * @param cwd - Current working directory + * @returns Absolute path to the server file, or null if none found + */ +function findServerFile(cwd: string): string | null { + for (const candidate of SERVER_FILE_CANDIDATES) { + const fullPath = path.join(cwd, candidate); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + return null; +} + +/** + * Represents a single named import extracted from the server file. + */ +interface ParsedImport { + /** The imported name (or local alias if renamed) */ + name: string; + /** The original exported name (differs from name when using `import { foo as bar }`) */ + originalName: string; + /** The module specifier (package name or relative path) */ + source: string; +} + +/** + * Extract all named imports from the AST root using structural node traversal. + * Handles single/double quotes, multiline imports, and aliased imports. + * + * @param root - AST root node + * @returns Array of parsed imports with name, original name, and source + */ +function parseImports(root: SgNode): ParsedImport[] { + const imports: ParsedImport[] = []; + + // Find all import_statement nodes in the AST + const importStatements = root.findAll({ + rule: { kind: "import_statement" }, + }); + + for (const stmt of importStatements) { + // Extract the module specifier (the string node, e.g. '@databricks/appkit') + const sourceNode = stmt.find({ rule: { kind: "string" } }); + if (!sourceNode) continue; + + // Strip surrounding quotes from the string node text + const source = sourceNode.text().replace(/^['"]|['"]$/g, ""); + + // Find named_imports block: { createApp, analytics, server } + const namedImports = stmt.find({ rule: { kind: "named_imports" } }); + if (!namedImports) continue; + + // Extract each import_specifier + const specifiers = namedImports.findAll({ + rule: { kind: "import_specifier" }, + }); + + for (const specifier of specifiers) { + const children = specifier.children(); + if (children.length >= 3) { + // Aliased import: `foo as bar` — children are [name, "as", alias] + const originalName = children[0].text(); + const localName = children[children.length - 1].text(); + imports.push({ name: localName, originalName, source }); + } else { + // Simple import: `foo` + const name = specifier.text(); + imports.push({ name, originalName: name, source }); + } + } + } + + return imports; +} + +/** + * Extract local names of plugins actually used in the `plugins: [...]` array + * passed to `createApp()`. Uses structural AST traversal to find `pair` nodes + * with key "plugins" and array values containing call expressions. + * + * @param root - AST root node + * @returns Set of local variable names used as plugin calls in the plugins array + */ +function parsePluginUsages(root: SgNode): Set { + const usedNames = new Set(); + + // Find all property pairs in the AST + const pairs = root.findAll({ rule: { kind: "pair" } }); + + for (const pair of pairs) { + // Check if the property key is "plugins" + const key = pair.find({ rule: { kind: "property_identifier" } }); + if (!key || key.text() !== "plugins") continue; + + // Find the array value + const arrayNode = pair.find({ rule: { kind: "array" } }); + if (!arrayNode) continue; + + // Iterate direct children of the array to find call expressions + for (const child of arrayNode.children()) { + if (child.kind() === "call_expression") { + // The callee is the first child (the identifier being called) + const callee = child.children()[0]; + if (callee?.kind() === "identifier") { + usedNames.add(callee.text()); + } + } + } + } + + return usedNames; +} + +/** + * File extensions to try when resolving a relative import to a file path. + */ +const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; + +/** + * Resolve a relative import source to the plugin directory containing a manifest.json. + * Follows the convention that plugins live in their own directory with a manifest.json. + * + * Resolution strategy: + * 1. If the import path is a directory, look for manifest.json directly in it + * 2. If the import path + extension is a file, look for manifest.json in its parent directory + * 3. If the import path is a directory with an index file, look for manifest.json in that directory + * + * @param importSource - The relative import specifier (e.g. "./plugins/my-plugin") + * @param serverFileDir - Absolute path to the directory containing the server file + * @returns Absolute path to manifest.json, or null if not found + */ +function resolveLocalManifest( + importSource: string, + serverFileDir: string, +): string | null { + const resolved = path.resolve(serverFileDir, importSource); + + // Case 1: Import path is a directory with manifest.json + // e.g. ./plugins/my-plugin → ./plugins/my-plugin/manifest.json + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + const manifestPath = path.join(resolved, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + } + + // Case 2: Import path + extension resolves to a file + // e.g. ./plugins/my-plugin → ./plugins/my-plugin.ts + // Look for manifest.json in the same directory + for (const ext of RESOLVE_EXTENSIONS) { + const filePath = `${resolved}${ext}`; + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const dir = path.dirname(filePath); + const manifestPath = path.join(dir, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + break; + } + } + + // Case 3: Import path is a directory with an index file + // e.g. ./plugins/my-plugin → ./plugins/my-plugin/index.ts + for (const ext of RESOLVE_EXTENSIONS) { + const indexPath = path.join(resolved, `index${ext}`); + if (fs.existsSync(indexPath)) { + const manifestPath = path.join(resolved, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + break; + } + } + + return null; +} + +/** + * Discover plugin manifests from local (relative) imports in the server file. + * Resolves each relative import to a directory and looks for manifest.json. + * + * @param relativeImports - Parsed imports with relative sources (starting with . or /) + * @param serverFileDir - Absolute path to the directory containing the server file + * @param cwd - Current working directory (for computing relative paths in output) + * @returns Map of plugin name to template plugin entry for local plugins + */ +function discoverLocalPlugins( + relativeImports: ParsedImport[], + serverFileDir: string, + cwd: string, +): TemplatePluginsManifest["plugins"] { + const plugins: TemplatePluginsManifest["plugins"] = {}; + + for (const imp of relativeImports) { + const manifestPath = resolveLocalManifest(imp.source, serverFileDir); + if (!manifestPath) continue; + + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as PluginManifest; + const relativePath = path.relative(cwd, path.dirname(manifestPath)); + + plugins[manifest.name] = { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: `./${relativePath}`, + resources: manifest.resources, + }; + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + error instanceof Error ? error.message : error, + ); + } + } + + return plugins; +} + +/** + * Discover plugin manifests from a package's dist folder. + * Looks for manifest.json files in dist/plugins/{plugin-name}/ directories. + * + * @param packagePath - Path to the package in node_modules + * @returns Array of plugin manifests found in the package + */ +function discoverPluginManifests(packagePath: string): PluginManifest[] { + const pluginsDir = path.join(packagePath, "dist", "plugins"); + const manifests: PluginManifest[] = []; + + if (!fs.existsSync(pluginsDir)) { + return manifests; + } + + const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const manifestPath = path.join(pluginsDir, entry.name, "manifest.json"); + if (fs.existsSync(manifestPath)) { + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as PluginManifest; + manifests.push(manifest); + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + error instanceof Error ? error.message : error, + ); + } + } + } + } + + return manifests; +} + +/** + * Scan node_modules for packages with plugin manifests. + * + * @param cwd - Current working directory to search from + * @param packages - Set of npm package names to scan for plugin manifests + * @returns Map of plugin name to template plugin entry + */ +function scanForPlugins( + cwd: string, + packages: Iterable, +): TemplatePluginsManifest["plugins"] { + const plugins: TemplatePluginsManifest["plugins"] = {}; + + for (const packageName of packages) { + const packagePath = path.join(cwd, "node_modules", packageName); + if (!fs.existsSync(packagePath)) { + continue; + } + + const manifests = discoverPluginManifests(packagePath); + for (const manifest of manifests) { + // Convert to template plugin format (exclude config schema) + plugins[manifest.name] = { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: packageName, + resources: manifest.resources, + }; + } + } + + return plugins; +} + +/** + * Run the plugins sync command. + * Parses the server entry file to discover which packages to scan for plugin + * manifests, then marks plugins that are actually used in the `plugins: [...]` + * array as requiredByTemplate. + */ +function runPluginsSync(options: { write?: boolean; output?: string }) { + const cwd = process.cwd(); + const outputPath = options.output || path.join(cwd, "appkit.plugins.json"); + + console.log("Scanning for AppKit plugins...\n"); + + // Step 1: Parse server file to discover imports and plugin usages + const serverFile = findServerFile(cwd); + let serverImports: ParsedImport[] = []; + let pluginUsages = new Set(); + + if (serverFile) { + const relativePath = path.relative(cwd, serverFile); + console.log(`Server entry file: ${relativePath}`); + + const content = fs.readFileSync(serverFile, "utf-8"); + const lang = serverFile.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript; + const ast = parse(lang, content); + const root = ast.root(); + + serverImports = parseImports(root); + pluginUsages = parsePluginUsages(root); + } else { + console.log( + "No server entry file found. Checked:", + SERVER_FILE_CANDIDATES.join(", "), + ); + } + + // Step 2: Split imports into npm packages and local (relative) imports + const npmImports = serverImports.filter( + (i) => !i.source.startsWith(".") && !i.source.startsWith("/"), + ); + const localImports = serverImports.filter( + (i) => i.source.startsWith(".") || i.source.startsWith("/"), + ); + + // Step 3: Scan npm packages for plugin manifests + const npmPackages = new Set([ + ...KNOWN_PLUGIN_PACKAGES, + ...npmImports.map((i) => i.source), + ]); + const plugins = scanForPlugins(cwd, npmPackages); + + // Step 4: Discover local plugin manifests from relative imports + if (serverFile && localImports.length > 0) { + const serverFileDir = path.dirname(serverFile); + const localPlugins = discoverLocalPlugins(localImports, serverFileDir, cwd); + Object.assign(plugins, localPlugins); + } + + const pluginCount = Object.keys(plugins).length; + + if (pluginCount === 0) { + console.log("No plugins found."); + console.log("\nMake sure you have plugin packages installed:"); + for (const pkg of npmPackages) { + console.log(` - ${pkg}`); + } + process.exit(1); + } + + // Step 5: Mark plugins that are imported AND used in the plugins array as mandatory. + // For npm imports, match by package name + plugin name. + // For local imports, resolve both paths to absolute and compare. + const serverFileDir = serverFile ? path.dirname(serverFile) : cwd; + + for (const imp of serverImports) { + if (!pluginUsages.has(imp.name)) continue; + + const isLocal = imp.source.startsWith(".") || imp.source.startsWith("/"); + let plugin: TemplatePlugin | undefined; + + if (isLocal) { + // Resolve the import source to an absolute path from the server file directory + const resolvedImportDir = path.resolve(serverFileDir, imp.source); + plugin = Object.values(plugins).find((p) => { + if (!p.package.startsWith(".")) return false; + const resolvedPluginDir = path.resolve(cwd, p.package); + return ( + resolvedPluginDir === resolvedImportDir && p.name === imp.originalName + ); + }); + } else { + // npm import: direct string comparison + plugin = Object.values(plugins).find( + (p) => p.package === imp.source && p.name === imp.originalName, + ); + } + + if (plugin) { + plugin.requiredByTemplate = true; + } + } + + console.log(`\nFound ${pluginCount} plugin(s):`); + for (const [name, manifest] of Object.entries(plugins)) { + const resourceCount = + manifest.resources.required.length + manifest.resources.optional.length; + const resourceInfo = + resourceCount > 0 ? ` [${resourceCount} resource(s)]` : ""; + const mandatoryTag = manifest.requiredByTemplate ? " (mandatory)" : ""; + console.log( + ` ${manifest.requiredByTemplate ? "●" : "○"} ${manifest.displayName} (${name}) from ${manifest.package}${resourceInfo}${mandatoryTag}`, + ); + } + + const templateManifest: TemplatePluginsManifest = { + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins, + }; + + if (options.write) { + fs.writeFileSync( + outputPath, + `${JSON.stringify(templateManifest, null, 2)}\n`, + ); + console.log(`\n✓ Wrote ${outputPath}`); + } else { + console.log("\nTo write the manifest, run:"); + console.log(" npx appkit plugins sync --write\n"); + console.log("Preview:"); + console.log("─".repeat(60)); + console.log(JSON.stringify(templateManifest, null, 2)); + console.log("─".repeat(60)); + } +} + +export const pluginsSyncCommand = new Command("sync") + .description( + "Sync plugin manifests from installed packages into appkit.plugins.json", + ) + .option("-w, --write", "Write the manifest file") + .option( + "-o, --output ", + "Output file path (default: ./appkit.plugins.json)", + ) + .action(runPluginsSync); diff --git a/packages/shared/src/cli/commands/plugins.ts b/packages/shared/src/cli/commands/plugins.ts new file mode 100644 index 00000000..ff1de368 --- /dev/null +++ b/packages/shared/src/cli/commands/plugins.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { pluginsSyncCommand } from "./plugins-sync.js"; + +/** + * Parent command for plugin management operations. + * Subcommands: + * - sync: Aggregate plugin manifests into appkit.plugins.json + * + * Future subcommands may include: + * - add: Add a plugin to an existing project + * - remove: Remove a plugin from a project + * - list: List available plugins + */ +export const pluginsCommand = new Command("plugins") + .description("Plugin management commands") + .addCommand(pluginsSyncCommand); diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 3b3c0293..23a19a53 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -7,6 +7,7 @@ import { Command } from "commander"; import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; +import { pluginsCommand } from "./commands/plugins.js"; import { setupCommand } from "./commands/setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -24,5 +25,6 @@ cmd.addCommand(setupCommand); cmd.addCommand(generateTypesCommand); cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); +cmd.addCommand(pluginsCommand); cmd.parse(); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index e390f835..5e42615c 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -6,8 +6,6 @@ export interface BasePlugin { abortActiveOperations?(): void; - validateEnv(): void; - setup(): Promise; injectRoutes(router: express.Router): void; diff --git a/template/.env.example.tmpl b/template/.env.example.tmpl index c8b5c441..0a7c80ec 100644 --- a/template/.env.example.tmpl +++ b/template/.env.example.tmpl @@ -3,5 +3,5 @@ DATABRICKS_HOST=https://... {{.dotenv_example}} {{- end}} DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME=minimal +DATABRICKS_APP_NAME={{.project_name}} FLASK_RUN_HOST=0.0.0.0 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json new file mode 100644 index 00000000..67f3874a --- /dev/null +++ b/template/appkit.plugins.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index cdfa3fe0..74ff0455 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -1,9 +1,9 @@ bundle: name: {{.project_name}} -{{if .bundle_variables}} +{{if .variables}} variables: -{{.bundle_variables}} +{{.variables}} {{- end}} resources: @@ -16,16 +16,15 @@ resources: # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files # user_api_scopes: # - sql -{{if .bundle_resources}} +{{if .resources}} # The resources which this app has access to. resources: -{{.bundle_resources}} +{{.resources}} {{- end}} targets: default: - # mode: production default: true workspace: host: {{workspace_host}} diff --git a/template/features/analytics/app_env.yml b/template/features/analytics/app_env.yml deleted file mode 100644 index 9228a9dd..00000000 --- a/template/features/analytics/app_env.yml +++ /dev/null @@ -1,2 +0,0 @@ - - name: DATABRICKS_WAREHOUSE_ID - valueFrom: warehouse diff --git a/template/features/analytics/bundle_resources.yml b/template/features/analytics/bundle_resources.yml deleted file mode 100644 index b3a631c0..00000000 --- a/template/features/analytics/bundle_resources.yml +++ /dev/null @@ -1,4 +0,0 @@ - - name: 'warehouse' - sql_warehouse: - id: ${var.warehouse_id} - permission: 'CAN_USE' diff --git a/template/features/analytics/bundle_variables.yml b/template/features/analytics/bundle_variables.yml deleted file mode 100644 index ac4fbf15..00000000 --- a/template/features/analytics/bundle_variables.yml +++ /dev/null @@ -1,2 +0,0 @@ - warehouse_id: - description: The ID of the warehouse to use diff --git a/template/features/analytics/dotenv.yml b/template/features/analytics/dotenv.yml deleted file mode 100644 index 7d17f13c..00000000 --- a/template/features/analytics/dotenv.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/template/features/analytics/dotenv_example.yml b/template/features/analytics/dotenv_example.yml deleted file mode 100644 index 1ae1aa74..00000000 --- a/template/features/analytics/dotenv_example.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID= diff --git a/template/features/analytics/target_variables.yml b/template/features/analytics/target_variables.yml deleted file mode 100644 index 0de7b63b..00000000 --- a/template/features/analytics/target_variables.yml +++ /dev/null @@ -1 +0,0 @@ - warehouse_id: {{.sql_warehouse_id}} diff --git a/template/package-lock.json b/template/package-lock.json index 84d74e11..6c5b2fbd 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -713,448 +713,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5888,9 +5446,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "license": "MIT", "peer": true, "funding": { @@ -8730,49 +8288,6 @@ "benchmarks" ] }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11592,12 +11107,12 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.17.2", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", - "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.10.1", + "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", @@ -11626,9 +11141,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", - "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -11955,9 +11470,9 @@ } }, "node_modules/react-day-picker": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz", - "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", "license": "MIT", "dependencies": { "@date-fns/tz": "^1.4.1", diff --git a/template/server/server.ts b/template/server/server.ts index da041927..e5f3b323 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,8 +1,7 @@ -import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; +import { createApp, {{.plugin_imports}} } from '@databricks/appkit'; createApp({ plugins: [ - server(), - {{.plugin_usage}}, + {{.plugin_usages}} ], }).catch(console.error); From 6c8ffabc913cd1fa36c9400b51b265c10552bdca Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 12 Feb 2026 09:27:09 +0100 Subject: [PATCH 5/9] chore: fixup --- packages/appkit/package.json | 1 + packages/appkit/src/index.ts | 1 - packages/appkit/src/registry/types.ts | 32 ++---- packages/shared/package.json | 1 + packages/shared/src/plugin.ts | 7 +- pnpm-lock.yaml | 150 +++++++++++++------------- 6 files changed, 88 insertions(+), 104 deletions(-) diff --git a/packages/appkit/package.json b/packages/appkit/package.json index b142fe36..c57e2bee 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -70,6 +70,7 @@ }, "devDependencies": { "@types/express": "^4.17.25", + "@types/json-schema": "^7.0.15", "@types/pg": "^8.15.6", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1" diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index d2391273..b0745592 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -34,7 +34,6 @@ export { analytics, server } from "./plugins"; // Registry types and utilities for plugin manifests export type { ConfigSchema, - ConfigSchemaProperty, PluginManifest, ResourceEntry, ResourceFieldEntry, diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index c9716fb5..f3488143 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -183,35 +183,15 @@ export interface ValidationResult { all: ResourceEntry[]; } -/** - * Configuration schema definition for plugin config. - * Uses JSON Schema format for validation and documentation. - */ -export interface ConfigSchema { - type: "object" | "array" | "string" | "number" | "boolean"; - properties?: Record; - items?: ConfigSchema; - required?: string[]; - additionalProperties?: boolean; - /** Allow additional JSON Schema properties */ - [key: string]: unknown; -} +import type { JSONSchema7 } from "json-schema"; /** - * Individual property definition in a config schema. + * Configuration schema definition for plugin config. + * Re-exported from the standard JSON Schema Draft 7 types. + * + * @see {@link https://json-schema.org/draft-07/json-schema-release-notes | JSON Schema Draft 7} */ -export interface ConfigSchemaProperty { - type: "object" | "array" | "string" | "number" | "boolean"; - description?: string; - default?: unknown; - enum?: unknown[]; - properties?: Record; - items?: ConfigSchemaProperty; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; -} +export type ConfigSchema = JSONSchema7; /** * Plugin manifest that declares metadata and resource requirements. diff --git a/packages/shared/package.json b/packages/shared/package.json index d7558056..484ad3c6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@types/dependency-tree": "^8.1.4", "@types/express": "^4.17.21", + "@types/json-schema": "^7.0.15", "@types/ws": "^8.18.1", "dependency-tree": "^11.2.0" }, diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 5e42615c..f781273d 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,4 +1,5 @@ import type express from "express"; +import type { JSONSchema7 } from "json-schema"; /** Base plugin interface. */ export interface BasePlugin { @@ -81,11 +82,7 @@ export interface PluginManifest { optional: Omit[]; }; config?: { - schema: { - type: string; - properties?: Record; - [key: string]: unknown; - }; + schema: JSONSchema7; }; author?: string; version?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 587a1d24..614f5d44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: '@types/express': specifier: ^4.17.25 version: 4.17.25 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/pg': specifier: ^8.15.6 version: 8.15.6 @@ -506,6 +509,9 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.23 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -3016,8 +3022,8 @@ packages: resolution: {integrity: sha512-Z7x2dZOmznihvdvCvLKMl+nswtOSVxS2H2ocar+U9xx6iMfTp0VGIrX6a4xB1v80IwOPC7dT1LXIJrY70Xu3Jw==} engines: {node: ^20.19.0 || >=22.12.0} - '@oxc-project/types@0.112.0': - resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-project/types@0.113.0': + resolution: {integrity: sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==} '@oxc-project/types@0.93.0': resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} @@ -3742,8 +3748,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + '@rolldown/binding-android-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -3754,8 +3760,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -3766,8 +3772,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.3': - resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.4': + resolution: {integrity: sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -3778,8 +3784,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': - resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.4': + resolution: {integrity: sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -3790,8 +3796,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': - resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': + resolution: {integrity: sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3802,8 +3808,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': + resolution: {integrity: sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3814,8 +3820,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': + resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3826,8 +3832,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': + resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3838,8 +3844,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': + resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3850,8 +3856,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3861,8 +3867,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': - resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': + resolution: {integrity: sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -3872,8 +3878,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': + resolution: {integrity: sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3890,8 +3896,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': + resolution: {integrity: sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3905,8 +3911,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.47': resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rolldown/pluginutils@1.0.0-rc.4': + resolution: {integrity: sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==} '@rollup/rollup-android-arm-eabi@4.52.4': resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} @@ -9744,8 +9750,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-rc.3: - resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + rolldown@1.0.0-rc.4: + resolution: {integrity: sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -12687,7 +12693,7 @@ snapshots: '@babel/preset-env': 7.28.5(@babel/core@7.28.5) '@babel/preset-react': 7.28.5(@babel/core@7.28.5) '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@babel/runtime-corejs3': 7.28.4 '@babel/traverse': 7.28.5 '@docusaurus/logger': 3.9.2 @@ -14701,7 +14707,7 @@ snapshots: '@oxc-project/runtime@0.92.0': {} - '@oxc-project/types@0.112.0': {} + '@oxc-project/types@0.113.0': {} '@oxc-project/types@0.93.0': {} @@ -15449,61 +15455,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.3': + '@rolldown/binding-android-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + '@rolldown/binding-darwin-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.41': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.3': + '@rolldown/binding-darwin-x64@1.0.0-rc.4': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.41': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + '@rolldown/binding-freebsd-x64@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': @@ -15511,7 +15517,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true @@ -15519,7 +15525,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': @@ -15528,7 +15534,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': optional: true '@rolldown/pluginutils@1.0.0-beta.38': {} @@ -15537,7 +15543,7 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.47': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.0-rc.4': {} '@rollup/rollup-android-arm-eabi@4.52.4': optional: true @@ -21906,7 +21912,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.103.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.0)' webpack: 5.103.0 @@ -21940,13 +21946,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react: 19.2.0 react-router: 5.3.4(react@19.2.0) react-router-dom@5.3.4(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -21957,7 +21963,7 @@ snapshots: react-router@5.3.4(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -22318,7 +22324,7 @@ snapshots: robust-predicates@3.0.2: {} - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.4)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.5 @@ -22329,7 +22335,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.12.0 magic-string: 0.30.19 - rolldown: 1.0.0-rc.3 + rolldown: 1.0.0-rc.4 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -22393,24 +22399,24 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.41 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.41 - rolldown@1.0.0-rc.3: + rolldown@1.0.0-rc.4: dependencies: - '@oxc-project/types': 0.112.0 - '@rolldown/pluginutils': 1.0.0-rc.3 + '@oxc-project/types': 0.113.0 + '@rolldown/pluginutils': 1.0.0-rc.4 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-x64': 1.0.0-rc.3 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + '@rolldown/binding-android-arm64': 1.0.0-rc.4 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.4 + '@rolldown/binding-darwin-x64': 1.0.0-rc.4 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.4 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.4 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.4 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 rollup@4.52.4: dependencies: @@ -23060,8 +23066,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown: 1.0.0-rc.4 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.4)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.1 tinyglobby: 0.2.15 From 701efcb12f35de9e5aa16ea025dac167d1db1187 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 12 Feb 2026 09:49:45 +0100 Subject: [PATCH 6/9] chore: fixup --- .../docs/api/appkit/Interface.ConfigSchema.md | 52 ------------ .../appkit/Interface.ConfigSchemaProperty.md | 83 ------------------- .../api/appkit/Interface.PluginManifest.md | 4 +- .../docs/api/appkit/TypeAlias.ConfigSchema.md | 12 +++ docs/docs/api/appkit/index.md | 3 +- docs/docs/api/appkit/typedoc-sidebar.ts | 15 ++-- docs/sidebars.ts | 2 +- docs/src/css/custom.css | 10 +-- .../src/plugins/analytics/manifest.json | 9 -- 9 files changed, 26 insertions(+), 164 deletions(-) delete mode 100644 docs/docs/api/appkit/Interface.ConfigSchema.md delete mode 100644 docs/docs/api/appkit/Interface.ConfigSchemaProperty.md create mode 100644 docs/docs/api/appkit/TypeAlias.ConfigSchema.md diff --git a/docs/docs/api/appkit/Interface.ConfigSchema.md b/docs/docs/api/appkit/Interface.ConfigSchema.md deleted file mode 100644 index 5ff1c797..00000000 --- a/docs/docs/api/appkit/Interface.ConfigSchema.md +++ /dev/null @@ -1,52 +0,0 @@ -# Interface: ConfigSchema - -Configuration schema definition for plugin config. -Uses JSON Schema format for validation and documentation. - -## Indexable - -```ts -[key: string]: unknown -``` - -Allow additional JSON Schema properties - -## Properties - -### additionalProperties? - -```ts -optional additionalProperties: boolean; -``` - -*** - -### items? - -```ts -optional items: ConfigSchema; -``` - -*** - -### properties? - -```ts -optional properties: Record; -``` - -*** - -### required? - -```ts -optional required: string[]; -``` - -*** - -### type - -```ts -type: "string" | "number" | "boolean" | "object" | "array"; -``` diff --git a/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md b/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md deleted file mode 100644 index c8fd10cd..00000000 --- a/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md +++ /dev/null @@ -1,83 +0,0 @@ -# Interface: ConfigSchemaProperty - -Individual property definition in a config schema. - -## Properties - -### default? - -```ts -optional default: unknown; -``` - -*** - -### description? - -```ts -optional description: string; -``` - -*** - -### enum? - -```ts -optional enum: unknown[]; -``` - -*** - -### items? - -```ts -optional items: ConfigSchemaProperty; -``` - -*** - -### maximum? - -```ts -optional maximum: number; -``` - -*** - -### maxLength? - -```ts -optional maxLength: number; -``` - -*** - -### minimum? - -```ts -optional minimum: number; -``` - -*** - -### minLength? - -```ts -optional minLength: number; -``` - -*** - -### properties? - -```ts -optional properties: Record; -``` - -*** - -### type - -```ts -type: "string" | "number" | "boolean" | "object" | "array"; -``` diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 00251a9f..8a376ac5 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -19,7 +19,7 @@ Optional metadata for community plugins ```ts optional config: { - schema: ConfigSchema; + schema: JSONSchema7; }; ``` @@ -29,7 +29,7 @@ Defines the shape and validation rules for plugin config. #### schema ```ts -schema: ConfigSchema; +schema: JSONSchema7; ``` *** diff --git a/docs/docs/api/appkit/TypeAlias.ConfigSchema.md b/docs/docs/api/appkit/TypeAlias.ConfigSchema.md new file mode 100644 index 00000000..6d07220e --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ConfigSchema.md @@ -0,0 +1,12 @@ +# Type Alias: ConfigSchema + +```ts +type ConfigSchema = JSONSchema7; +``` + +Configuration schema definition for plugin config. +Re-exported from the standard JSON Schema Draft 7 types. + +## See + +[JSON Schema Draft 7](https://json-schema.org/draft-07/json-schema-release-notes) diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 11282b99..de8724e7 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -31,8 +31,6 @@ plugin architecture, and React integration. | ------ | ------ | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for caching | -| [ConfigSchema](Interface.ConfigSchema.md) | Configuration schema definition for plugin config. Uses JSON Schema format for validation and documentation. | -| [ConfigSchemaProperty](Interface.ConfigSchemaProperty.md) | Individual property definition in a config schema. | | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | @@ -46,6 +44,7 @@ plugin architecture, and React integration. | Type Alias | Description | | ------ | ------ | +| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | | [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 9fa0c956..aa114b63 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -87,16 +87,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, - { - type: "doc", - id: "api/appkit/Interface.ConfigSchema", - label: "ConfigSchema" - }, - { - type: "doc", - id: "api/appkit/Interface.ConfigSchemaProperty", - label: "ConfigSchemaProperty" - }, { type: "doc", id: "api/appkit/Interface.ITelemetry", @@ -143,6 +133,11 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Type Aliases", items: [ + { + type: "doc", + id: "api/appkit/TypeAlias.ConfigSchema", + label: "ConfigSchema" + }, { type: "doc", id: "api/appkit/TypeAlias.IAppRouter", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index f7d99e0f..1dd673a3 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -20,7 +20,7 @@ const SUPPORTED_KINDS = new Set([ "typealias", "function", "variable", - "enum", + "enumeration", ]); function flattenSidebarWithKind(sidebarConfig: any): any[] { diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 1301f74a..d3f3ad1d 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -26,7 +26,7 @@ --api-kind-typealias-color: #ec4899; --api-kind-function-color: #10b981; --api-kind-variable-color: #f59e0b; - --api-kind-enum-color: #6366f1; + --api-kind-enumeration-color: #6366f1; } [data-theme="light"] { @@ -62,7 +62,7 @@ .api-kind-typealias .menu__link::before, .api-kind-function .menu__link::before, .api-kind-variable .menu__link::before, -.api-kind-enum .menu__link::before, +.api-kind-enumeration .menu__link::before, .api-kind-other .menu__link::before { display: inline-block; width: 1.5em; @@ -109,9 +109,9 @@ content: "V"; } -.api-kind-enum .menu__link::before { - border-color: var(--api-kind-enum-color); - color: var(--api-kind-enum-color); +.api-kind-enumeration .menu__link::before { + border-color: var(--api-kind-enumeration-color); + color: var(--api-kind-enumeration-color); content: "E"; } diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index 7eb79313..4a6a60c2 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -29,15 +29,6 @@ "type": "number", "default": 30000, "description": "Query execution timeout in milliseconds" - }, - "queriesDir": { - "type": "string", - "description": "Directory containing SQL query files" - }, - "cacheEnabled": { - "type": "boolean", - "default": true, - "description": "Enable query result caching" } } } From 4486bf236f03ee16553f9b35fbbe638a6e5f4904 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 12 Feb 2026 12:44:50 +0100 Subject: [PATCH 7/9] chore: fixup --- .../docs/api/appkit/Class.ResourceRegistry.md | 28 ++- .../api/appkit/Interface.ResourceEntry.md | 12 ++ docs/docs/plugins.md | 16 +- docs/scripts/copy-schemas.ts | 5 +- docs/static/appkit-ui/styles.gen.css | 3 + .../appkit/src/registry/resource-registry.ts | 82 +++++++- .../registry/tests/resource-registry.test.ts | 187 +++++++++++++++++- packages/appkit/src/registry/types.ts | 7 + packages/appkit/tsdown.config.ts | 10 - packages/shared/package.json | 2 + .../shared/src/cli/commands/plugins-sync.ts | 144 +++++++++++++- packages/shared/src/plugin.ts | 21 +- .../src}/schemas/plugin-manifest.schema.json | 0 .../src}/schemas/template-plugins.schema.json | 0 packages/shared/tsdown.config.ts | 10 + pnpm-lock.yaml | 18 ++ 16 files changed, 508 insertions(+), 37 deletions(-) rename packages/{appkit/src/registry => shared/src}/schemas/plugin-manifest.schema.json (100%) rename packages/{appkit/src/registry => shared/src}/schemas/template-plugins.schema.json (100%) diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md index 6a964c39..03e55042 100644 --- a/docs/docs/api/appkit/Class.ResourceRegistry.md +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -54,7 +54,8 @@ enforceValidation(): ValidationResult; Validates all registered resources and enforces the result. - In production: throws a [ConfigurationError](Class.ConfigurationError.md) if any required resources are missing. -- In development (`NODE_ENV=development`): logs a warning but continues. +- In development (`NODE_ENV=development`): logs a warning but continues, unless + `APPKIT_STRICT_VALIDATION=true` is set, in which case throws like production. - When all resources are valid: logs a debug message with the count. #### Returns @@ -65,7 +66,7 @@ ValidationResult with validity status, missing resources, and all resources #### Throws -In production when required resources are missing +In production when required resources are missing, or in dev when APPKIT_STRICT_VALIDATION=true *** @@ -237,6 +238,29 @@ if (!result.valid) { *** +### formatDevWarningBanner() + +```ts +static formatDevWarningBanner(missing: ResourceEntry[]): string; +``` + +Formats a highly visible warning banner for dev-mode missing resources. +Uses box drawing to ensure the message is impossible to miss in scrolling logs. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `missing` | [`ResourceEntry`](Interface.ResourceEntry.md)[] | Array of missing resource entries | + +#### Returns + +`string` + +Formatted banner string + +*** + ### formatMissingResources() ```ts diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md index f6559c80..c56a226a 100644 --- a/docs/docs/api/appkit/Interface.ResourceEntry.md +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -66,6 +66,18 @@ Required permission level for the resource *** +### permissionSources? + +```ts +optional permissionSources: Record; +``` + +Per-plugin permission tracking. +Maps plugin name to the permission it originally requested. +Populated when multiple plugins share the same resource. + +*** + ### plugin ```ts diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 16475245..4fa9fa5d 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -230,9 +230,13 @@ class MyPlugin extends Plugin { { type: "secret", alias: "apiKey", + resourceKey: "apiKey", description: "API key for external service", permission: "READ", - env: "MY_API_KEY" + fields: { + scope: { env: "MY_SECRET_SCOPE", description: "Secret scope" }, + key: { env: "MY_API_KEY", description: "Secret key name" } + } } ], optional: [] @@ -285,11 +289,11 @@ class MyPlugin extends Plugin { description: "A plugin with optional caching", resources: { required: [ - { type: "sql_warehouse", alias: "warehouse", description: "Query execution", permission: "CAN_USE" } + { type: "sql_warehouse", alias: "warehouse", resourceKey: "sqlWarehouse", description: "Query execution", permission: "CAN_USE", fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } } } ], optional: [ // Listed as optional in manifest for static analysis - { type: "database", alias: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE" } + { type: "database", alias: "cache", resourceKey: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE", fields: { instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, database_name: { env: "DATABRICKS_CACHE_DB" } } } ] } }; @@ -302,9 +306,13 @@ class MyPlugin extends Plugin { resources.push({ type: "database", alias: "cache", + resourceKey: "cache", description: "Query result caching", permission: "CAN_CONNECT_AND_CREATE", - env: "DATABRICKS_DATABASE_ID", + fields: { + instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, + database_name: { env: "DATABRICKS_CACHE_DB" }, + }, required: true // Mark as required at runtime }); } diff --git a/docs/scripts/copy-schemas.ts b/docs/scripts/copy-schemas.ts index c519ddbd..7a465eb0 100644 --- a/docs/scripts/copy-schemas.ts +++ b/docs/scripts/copy-schemas.ts @@ -11,10 +11,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SCHEMAS_SOURCE = join( - __dirname, - "../../packages/appkit/src/registry/schemas", -); +const SCHEMAS_SOURCE = join(__dirname, "../../packages/shared/src/schemas"); const SCHEMAS_DEST = join(__dirname, "../static/schemas"); function copySchemas() { diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index e497c06a..a9f095f5 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -221,6 +221,9 @@ .invisible { visibility: hidden; } + .visible { + visibility: visible; + } .sr-only { position: absolute; width: 1px; diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts index 459bdafb..be1a88f8 100644 --- a/packages/appkit/src/registry/resource-registry.ts +++ b/packages/appkit/src/registry/resource-registry.ts @@ -104,11 +104,12 @@ export class ResourceRegistry { const merged = this.mergeResources(existing, plugin, resource); this.resources.set(key, merged); } else { - // Create new resource entry + // Create new resource entry with permission source tracking const entry: ResourceEntry = { ...resource, plugin, resolved: false, + permissionSources: { [plugin]: resource.permission }, }; this.resources.set(key, entry); } @@ -189,12 +190,31 @@ export class ResourceRegistry { plugins.push(newPlugin); } - // Use the most permissive permission + // Track per-plugin permission sources + const permissionSources: Record = { + ...(existing.permissionSources ?? {}), + [newPlugin]: newResource.permission, + }; + + // Use the most permissive permission, but warn when escalating const permission = getMostPermissivePermission( existing.permission, newResource.permission, ); + if (permission !== existing.permission) { + logger.warn( + 'Resource %s:%s permission escalated from "%s" to "%s" due to plugin "%s" ' + + "(previously requested by: %s). Review plugin permissions to ensure least-privilege.", + existing.type, + existing.alias, + existing.permission, + permission, + newPlugin, + existing.plugin, + ); + } + // Mark as required if any plugin requires it const required = existing.required || newResource.required; @@ -217,6 +237,7 @@ export class ResourceRegistry { ...existing, plugin: plugins.join(", "), permission, + permissionSources, required, description, fields, @@ -366,27 +387,28 @@ export class ResourceRegistry { * Validates all registered resources and enforces the result. * * - In production: throws a {@link ConfigurationError} if any required resources are missing. - * - In development (`NODE_ENV=development`): logs a warning but continues. + * - In development (`NODE_ENV=development`): logs a warning but continues, unless + * `APPKIT_STRICT_VALIDATION=true` is set, in which case throws like production. * - When all resources are valid: logs a debug message with the count. * * @returns ValidationResult with validity status, missing resources, and all resources - * @throws {ConfigurationError} In production when required resources are missing + * @throws {ConfigurationError} In production when required resources are missing, or in dev when APPKIT_STRICT_VALIDATION=true */ public enforceValidation(): ValidationResult { const validation = this.validate(); const isDevelopment = process.env.NODE_ENV === "development"; + const strictValidation = + process.env.APPKIT_STRICT_VALIDATION === "true" || + process.env.APPKIT_STRICT_VALIDATION === "1"; if (!validation.valid) { const errorMessage = ResourceRegistry.formatMissingResources( validation.missing, ); - if (isDevelopment) { - logger.warn( - "Missing resources detected (continuing in dev mode):\n%s", - errorMessage, - ); - } else { + const shouldThrow = !isDevelopment || strictValidation; + + if (shouldThrow) { throw new ConfigurationError(errorMessage, { context: { missingResources: validation.missing.map((r) => ({ @@ -398,6 +420,12 @@ export class ResourceRegistry { }, }); } + + // Dev mode without strict: use a visually prominent box so the warning can't be missed + const banner = ResourceRegistry.formatDevWarningBanner( + validation.missing, + ); + logger.warn("\n%s", banner); } else if (this.size() > 0) { logger.debug("All %d resources validated successfully", this.size()); } @@ -424,4 +452,38 @@ export class ResourceRegistry { return `Missing required resources:\n${lines.join("\n")}`; } + + /** + * Formats a highly visible warning banner for dev-mode missing resources. + * Uses box drawing to ensure the message is impossible to miss in scrolling logs. + * + * @param missing - Array of missing resource entries + * @returns Formatted banner string + */ + public static formatDevWarningBanner(missing: ResourceEntry[]): string { + const contentLines: string[] = [ + "MISSING REQUIRED RESOURCES (dev mode — would fail in production)", + "", + ]; + + for (const entry of missing) { + const envVars = Object.values(entry.fields).map((f) => f.env); + contentLines.push( + ` ${entry.type}:${entry.alias} (plugin: ${entry.plugin})`, + ); + contentLines.push(` Set: ${envVars.join(", ")}`); + } + + contentLines.push(""); + contentLines.push( + "Add these to your .env file or environment to suppress this warning.", + ); + + const maxLen = Math.max(...contentLines.map((l) => l.length)); + const border = "=".repeat(maxLen + 4); + + const boxed = contentLines.map((line) => `| ${line.padEnd(maxLen)} |`); + + return [border, ...boxed, border].join("\n"); + } } diff --git a/packages/appkit/src/registry/tests/resource-registry.test.ts b/packages/appkit/src/registry/tests/resource-registry.test.ts index 1abf5853..23b0b604 100644 --- a/packages/appkit/src/registry/tests/resource-registry.test.ts +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ResourceRegistry } from "../resource-registry"; import { ResourceType } from "../types"; @@ -211,6 +211,191 @@ describe("ResourceRegistry", () => { }); }); + describe("permission escalation tracking", () => { + it("should track permissionSources for a single plugin", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permissionSources).toEqual({ "plugin-a": "CAN_USE" }); + }); + + it("should track permissionSources when merging multiple plugins", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_MANAGE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_MANAGE"); + expect(entry?.permissionSources).toEqual({ + "plugin-a": "CAN_USE", + "plugin-b": "CAN_MANAGE", + }); + }); + + it("should warn when permission is escalated during merge", () => { + const registry = ResourceRegistry.getInstance(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_MANAGE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + // The logger uses debug/console under the hood — verify final permission + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_MANAGE"); + + warnSpy.mockRestore(); + }); + + it("should not escalate when permissions are identical", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: false, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_USE"); + expect(entry?.permissionSources).toEqual({ + "plugin-a": "CAN_USE", + "plugin-b": "CAN_USE", + }); + }); + }); + + describe("enforceValidation with APPKIT_STRICT_VALIDATION", () => { + it("should throw in dev when APPKIT_STRICT_VALIDATION=true", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + delete process.env.DATABRICKS_WAREHOUSE_ID; + + const origNodeEnv = process.env.NODE_ENV; + const origStrict = process.env.APPKIT_STRICT_VALIDATION; + process.env.NODE_ENV = "development"; + process.env.APPKIT_STRICT_VALIDATION = "true"; + try { + expect(() => registry.enforceValidation()).toThrow(); + } finally { + process.env.NODE_ENV = origNodeEnv; + process.env.APPKIT_STRICT_VALIDATION = origStrict ?? ""; + } + }); + + it("should only warn in dev when APPKIT_STRICT_VALIDATION is not set", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + delete process.env.DATABRICKS_WAREHOUSE_ID; + + const origNodeEnv = process.env.NODE_ENV; + delete process.env.APPKIT_STRICT_VALIDATION; + process.env.NODE_ENV = "development"; + try { + const result = registry.enforceValidation(); + expect(result.valid).toBe(false); + } finally { + process.env.NODE_ENV = origNodeEnv; + } + }); + }); + + describe("enforceValidation dev warning banner", () => { + it("should format a visible banner for dev mode", () => { + const banner = ResourceRegistry.formatDevWarningBanner([ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + required: true, + plugin: "analytics", + resolved: false, + }, + ]); + + expect(banner).toContain("MISSING REQUIRED RESOURCES"); + expect(banner).toContain("would fail in production"); + expect(banner).toContain("sql_warehouse:warehouse"); + expect(banner).toContain("DATABRICKS_WAREHOUSE_ID"); + expect(banner).toContain("analytics"); + expect(banner).toContain(".env"); + // Should have box borders + expect(banner).toContain("===="); + expect(banner).toContain("|"); + }); + }); + describe("formatMissingResources with fields", () => { it("should list field env vars for multi-field missing resources", () => { const registry = ResourceRegistry.getInstance(); diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index f3488143..d1781ad1 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -167,6 +167,13 @@ export interface ResourceEntry extends ResourceRequirement { /** Resolved value per field name. Populated by validate() when all field env vars are set. */ values?: Record; + + /** + * Per-plugin permission tracking. + * Maps plugin name to the permission it originally requested. + * Populated when multiple plugins share the same resource. + */ + permissionSources?: Record; } /** diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index ad8c46be..2472c084 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -46,16 +46,6 @@ export default defineConfig([ from: "src/plugins/server/manifest.json", to: "dist/plugins/server/manifest.json", }, - // JSON Schema for plugin manifests - { - from: "src/registry/schemas/plugin-manifest.schema.json", - to: "dist/registry/schemas/plugin-manifest.schema.json", - }, - // JSON Schema for template plugins manifest - { - from: "src/registry/schemas/template-plugins.schema.json", - to: "dist/registry/schemas/template-plugins.schema.json", - }, ], }, ]); diff --git a/packages/shared/package.json b/packages/shared/package.json index 484ad3c6..df890905 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,8 @@ }, "dependencies": { "@ast-grep/napi": "^0.37.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "commander": "^12.1.0" } } diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugins-sync.ts index 7b0cad3d..865cca85 100644 --- a/packages/shared/src/cli/commands/plugins-sync.ts +++ b/packages/shared/src/cli/commands/plugins-sync.ts @@ -1,8 +1,22 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { Lang, parse, type SgNode } from "@ast-grep/napi"; +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; import { Command } from "commander"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Resolve to package schemas: from dist/cli/commands -> dist/schemas, from src/cli/commands -> shared/schemas +const PLUGIN_MANIFEST_SCHEMA_PATH = path.join( + __dirname, + "..", + "..", + "..", + "schemas", + "plugin-manifest.schema.json", +); + /** * Field entry in a resource requirement (env var + optional description) */ @@ -56,6 +70,102 @@ interface TemplatePluginsManifest { plugins: Record; } +/** + * Checks whether a resolved file path is within a given directory boundary. + * Uses path.resolve + startsWith to prevent directory traversal. + * + * @param filePath - The path to check (will be resolved to absolute) + * @param boundary - The directory that must contain filePath + * @returns true if filePath is inside boundary (or equal to it) + */ +function isWithinDirectory(filePath: string, boundary: string): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedBoundary = path.resolve(boundary); + // Append separator to avoid prefix false-positives (e.g. /foo-bar matching /foo) + return ( + resolvedPath === resolvedBoundary || + resolvedPath.startsWith(`${resolvedBoundary}${path.sep}`) + ); +} + +let pluginManifestValidator: ReturnType | null = null; + +/** + * Loads and compiles the plugin-manifest JSON schema (cached). + * Returns the compiled validate function or null if the schema cannot be loaded. + */ +function getPluginManifestValidator(): ReturnType | null { + if (pluginManifestValidator) return pluginManifestValidator; + try { + const schemaRaw = fs.readFileSync(PLUGIN_MANIFEST_SCHEMA_PATH, "utf-8"); + const schema = JSON.parse(schemaRaw) as object; + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + pluginManifestValidator = ajv.compile(schema); + return pluginManifestValidator; + } catch (err) { + console.warn( + "Warning: Could not load plugin-manifest schema for validation:", + err instanceof Error ? err.message : err, + ); + return null; + } +} + +/** + * Validates a parsed JSON object against the plugin-manifest JSON schema. + * Returns the manifest if valid, or null and logs schema errors. + * + * @param obj - The parsed JSON object to validate + * @param sourcePath - Path to the manifest file (for warning messages) + * @returns A valid PluginManifest or null + */ +function validateManifestWithSchema( + obj: unknown, + sourcePath: string, +): PluginManifest | null { + if (!obj || typeof obj !== "object") { + console.warn(`Warning: Manifest at ${sourcePath} is not a valid object`); + return null; + } + + const validate = getPluginManifestValidator(); + if (!validate) { + // Schema not available (e.g. dev without build); fall back to basic shape check + const m = obj as Record; + if ( + typeof m.name === "string" && + m.name.length > 0 && + typeof m.displayName === "string" && + m.displayName.length > 0 && + typeof m.description === "string" && + m.description.length > 0 && + m.resources && + typeof m.resources === "object" && + Array.isArray((m.resources as { required?: unknown }).required) + ) { + return obj as PluginManifest; + } + console.warn(`Warning: Manifest at ${sourcePath} has invalid structure`); + return null; + } + + const valid = validate(obj); + if (valid) return obj as PluginManifest; + + const errors: ErrorObject[] = validate.errors ?? []; + const message = errors + .map( + (e: ErrorObject) => + ` ${e.instancePath || "/"} ${e.message}${e.params ? ` (${JSON.stringify(e.params)})` : ""}`, + ) + .join("\n"); + console.warn( + `Warning: Manifest at ${sourcePath} failed schema validation:\n${message}`, + ); + return null; +} + /** * Known packages that may contain AppKit plugins. * Always scanned for manifests, even if not imported in the server file. @@ -205,9 +315,19 @@ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; function resolveLocalManifest( importSource: string, serverFileDir: string, + projectRoot?: string, ): string | null { const resolved = path.resolve(serverFileDir, importSource); + // Security: Reject paths that escape the project root + const boundary = projectRoot || serverFileDir; + if (!isWithinDirectory(resolved, boundary)) { + console.warn( + `Warning: Skipping import "${importSource}" — resolves outside the project directory`, + ); + return null; + } + // Case 1: Import path is a directory with manifest.json // e.g. ./plugins/my-plugin → ./plugins/my-plugin/manifest.json if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { @@ -259,12 +379,15 @@ function discoverLocalPlugins( const plugins: TemplatePluginsManifest["plugins"] = {}; for (const imp of relativeImports) { - const manifestPath = resolveLocalManifest(imp.source, serverFileDir); + const manifestPath = resolveLocalManifest(imp.source, serverFileDir, cwd); if (!manifestPath) continue; try { const content = fs.readFileSync(manifestPath, "utf-8"); - const manifest = JSON.parse(content) as PluginManifest; + const parsed = JSON.parse(content); + const manifest = validateManifestWithSchema(parsed, manifestPath); + if (!manifest) continue; + const relativePath = path.relative(cwd, path.dirname(manifestPath)); plugins[manifest.name] = { @@ -307,8 +430,11 @@ function discoverPluginManifests(packagePath: string): PluginManifest[] { if (fs.existsSync(manifestPath)) { try { const content = fs.readFileSync(manifestPath, "utf-8"); - const manifest = JSON.parse(content) as PluginManifest; - manifests.push(manifest); + const parsed = JSON.parse(content); + const manifest = validateManifestWithSchema(parsed, manifestPath); + if (manifest) { + manifests.push(manifest); + } } catch (error) { console.warn( `Warning: Failed to parse manifest at ${manifestPath}:`, @@ -365,7 +491,15 @@ function scanForPlugins( */ function runPluginsSync(options: { write?: boolean; output?: string }) { const cwd = process.cwd(); - const outputPath = options.output || path.join(cwd, "appkit.plugins.json"); + const outputPath = path.resolve(cwd, options.output || "appkit.plugins.json"); + + // Security: Reject output paths that escape the project root + if (!isWithinDirectory(outputPath, cwd)) { + console.error( + `Error: Output path "${options.output}" resolves outside the project directory.`, + ); + process.exit(1); + } console.log("Scanning for AppKit plugins...\n"); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index f781273d..54d8f583 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -91,6 +91,19 @@ export interface PluginManifest { license?: string; } +/** + * Defines a single field for a resource. + * Each field maps to its own environment variable and optional description. + * Single-value types use one key (e.g. id); multi-value types (database, secret) + * use multiple (e.g. instance_name, database_name or scope, key). + */ +export interface ResourceFieldEntry { + /** Environment variable name for this field */ + env: string; + /** Human-readable description for this field */ + description?: string; +} + /** * Resource requirement declaration (imported from registry types). * Re-exported here to avoid circular dependencies. @@ -98,9 +111,15 @@ export interface PluginManifest { export interface ResourceRequirement { type: string; alias: string; + /** Stable key for machine use (env naming, composite keys, app.yaml). */ + resourceKey: string; description: string; permission: string; - env?: string; + /** + * Map of field name to env and optional description. + * Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. + */ + fields: Record; required: boolean; } diff --git a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json similarity index 100% rename from packages/appkit/src/registry/schemas/plugin-manifest.schema.json rename to packages/shared/src/schemas/plugin-manifest.schema.json diff --git a/packages/appkit/src/registry/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json similarity index 100% rename from packages/appkit/src/registry/schemas/template-plugins.schema.json rename to packages/shared/src/schemas/template-plugins.schema.json diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index b1fdb9c5..98128e34 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -18,4 +18,14 @@ export default defineConfig({ exports: { devExports: "development", }, + copy: [ + { + from: "src/schemas/plugin-manifest.schema.json", + to: "dist/schemas/plugin-manifest.schema.json", + }, + { + from: "src/schemas/template-plugins.schema.json", + to: "dist/schemas/template-plugins.schema.json", + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 614f5d44..4b598b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,6 +499,12 @@ importers: '@ast-grep/napi': specifier: ^0.37.0 version: 0.37.0 + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) commander: specifier: ^12.1.0 version: 12.1.0 @@ -4916,6 +4922,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -16719,6 +16733,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 From c2b566fad855e6889ec7b51f6aea9e91912ea500 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 12 Feb 2026 13:05:36 +0100 Subject: [PATCH 8/9] chore: fixup --- .../docs/api/appkit/Class.ResourceRegistry.md | 66 +++--- .../api/appkit/Function.getPluginManifest.md | 18 +- docs/docs/api/appkit/index.md | 4 +- .../schemas/plugin-manifest.schema.json | 4 +- packages/appkit/src/core/appkit.ts | 4 +- .../appkit/src/registry/manifest-loader.ts | 190 +++++++++++------- .../appkit/src/registry/resource-registry.ts | 182 ++++++++--------- .../registry/tests/resource-registry.test.ts | 38 ++-- packages/appkit/src/registry/types.ts | 29 +++ .../src/schemas/plugin-manifest.schema.json | 4 +- 10 files changed, 270 insertions(+), 269 deletions(-) diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md index 03e55042..e8c4d841 100644 --- a/docs/docs/api/appkit/Class.ResourceRegistry.md +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -1,7 +1,19 @@ # Class: ResourceRegistry Central registry for tracking plugin resource requirements. -Implements singleton pattern to ensure a single source of truth. +Deduplication uses type + resourceKey (machine-stable); alias is for display only. + +## Constructors + +### Constructor + +```ts +new ResourceRegistry(): ResourceRegistry; +``` + +#### Returns + +`ResourceRegistry` ## Methods @@ -27,11 +39,7 @@ collectResources(rawPlugins: PluginData[]): ``` Collects and registers resource requirements from an array of plugins. -For each plugin, loads its manifest to discover static resource declarations, -then checks for runtime resource requirements via `getResourceRequirements()`. - -Plugins without manifests are silently skipped (allowed for legacy plugins -or plugins that don't declare resources). +For each plugin, loads its manifest (required) and runtime resource requirements. #### Parameters @@ -43,6 +51,10 @@ or plugins that don't declare resources). `void` +#### Throws + +If any plugin is missing a manifest or manifest is invalid + *** ### enforceValidation() @@ -73,17 +85,17 @@ In production when required resources are missing, or in dev when APPKIT_STRICT_ ### get() ```ts -get(type: string, alias: string): ResourceEntry | undefined; +get(type: string, resourceKey: string): ResourceEntry | undefined; ``` -Gets a specific resource by type and alias. +Gets a specific resource by type and resourceKey (dedup key). #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | | `type` | `string` | Resource type | -| `alias` | `string` | Resource alias | +| `resourceKey` | `string` | Stable machine key (not alias; alias is for display only) | #### Returns @@ -171,12 +183,12 @@ register(plugin: string, resource: ResourceRequirement): void; ``` Registers a resource requirement for a plugin. -If a resource with the same type+alias already exists, merges them: +If a resource with the same type+resourceKey already exists, merges them: - Combines plugin names (comma-separated) -- Uses the most permissive permission +- Uses the most permissive permission (per-type hierarchy) - Marks as required if any plugin requires it - Combines descriptions if they differ -- Keeps the env variable (or merges if they differ) +- Merges fields; warns when same field name uses different env vars #### Parameters @@ -280,33 +292,3 @@ Formats missing resources into a human-readable error message. `string` Formatted error message string - -*** - -### getInstance() - -```ts -static getInstance(): ResourceRegistry; -``` - -Gets the singleton instance of the ResourceRegistry. -Creates a new instance if one doesn't exist. - -#### Returns - -`ResourceRegistry` - -*** - -### resetInstance() - -```ts -static resetInstance(): void; -``` - -Resets the singleton instance. -Primarily used for testing to ensure clean state between tests. - -#### Returns - -`void` diff --git a/docs/docs/api/appkit/Function.getPluginManifest.md b/docs/docs/api/appkit/Function.getPluginManifest.md index 3afb325d..1d5a3e01 100644 --- a/docs/docs/api/appkit/Function.getPluginManifest.md +++ b/docs/docs/api/appkit/Function.getPluginManifest.md @@ -5,9 +5,7 @@ function getPluginManifest(plugin: PluginConstructor): PluginManifest; ``` Loads and validates the manifest from a plugin constructor. - -All plugins must have a static `manifest` property that declares their -metadata and resource requirements. +Normalizes string type/permission to strict ResourceType/ResourcePermission. ## Parameters @@ -19,18 +17,8 @@ metadata and resource requirements. [`PluginManifest`](Interface.PluginManifest.md) -The validated plugin manifest +The validated, normalized plugin manifest ## Throws -If the manifest is missing or invalid - -## Example - -```typescript -import { AnalyticsPlugin } from '@databricks/appkit'; -import { getPluginManifest } from './manifest-loader'; - -const manifest = getPluginManifest(AnalyticsPlugin); -console.log('Required resources:', manifest.resources.required); -``` +If the manifest is missing, invalid, or has invalid resource type/permission diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index de8724e7..f1a0e5f8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -20,7 +20,7 @@ plugin architecture, and React integration. | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | | [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | -| [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Implements singleton pattern to ensure a single source of truth. | +| [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Deduplication uses type + resourceKey (machine-stable); alias is for display only. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | @@ -61,6 +61,6 @@ plugin architecture, and React integration. | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | -| [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. | +| [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. Normalizes string type/permission to strict ResourceType/ResourcePermission. | | [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index aa3fd137..8f8c9feb 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -220,13 +220,13 @@ "alias": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin (UI/display)", + "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", "examples": ["SQL Warehouse", "Secret", "Vector search index"] }, "resourceKey": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", "examples": ["sql-warehouse", "database", "secret"] }, "description": { diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 81a5223e..86f74e0d 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -154,9 +154,7 @@ export class AppKit { const rawPlugins = config.plugins as T; - const registry = ResourceRegistry.getInstance(); - - registry.clear(); + const registry = new ResourceRegistry(); registry.collectResources(rawPlugins); registry.enforceValidation(); diff --git a/packages/appkit/src/registry/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts index 5e0dcab6..5c58f1fd 100644 --- a/packages/appkit/src/registry/manifest-loader.ts +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -1,99 +1,135 @@ import type { PluginConstructor } from "shared"; import { ConfigurationError } from "../errors"; import { createLogger } from "../logging/logger"; -import type { PluginManifest } from "./types"; +import type { + PluginManifest, + ResourcePermission, + ResourceRequirement, +} from "./types"; +import { PERMISSIONS_BY_TYPE, ResourceType } from "./types"; const logger = createLogger("manifest-loader"); +/** Loose resource from shared/manifest (string type and permission). */ +interface LooseResource { + type: string; + alias: string; + resourceKey: string; + description: string; + permission: string; + fields: Record; +} + +function normalizeType(s: string): ResourceType { + const v = Object.values(ResourceType).find((x) => x === s); + if (v !== undefined) return v; + throw new ConfigurationError( + `Invalid resource type: "${s}". Valid: ${Object.values(ResourceType).join(", ")}`, + ); +} + +function normalizePermission( + type: ResourceType, + s: string, +): ResourcePermission { + const allowed = PERMISSIONS_BY_TYPE[type]; + if (allowed.includes(s as ResourcePermission)) return s as ResourcePermission; + throw new ConfigurationError( + `Invalid permission "${s}" for type ${type}. Valid: ${allowed.join(", ")}`, + ); +} + +function normalizeResource(r: LooseResource): ResourceRequirement { + const type = normalizeType(r.type); + const permission = normalizePermission(type, r.permission); + return { + ...r, + type, + permission, + required: false, + }; +} + /** * Loads and validates the manifest from a plugin constructor. - * - * All plugins must have a static `manifest` property that declares their - * metadata and resource requirements. + * Normalizes string type/permission to strict ResourceType/ResourcePermission. * * @param plugin - The plugin constructor class - * @returns The validated plugin manifest - * @throws {ConfigurationError} If the manifest is missing or invalid - * - * @example - * ```typescript - * import { AnalyticsPlugin } from '@databricks/appkit'; - * import { getPluginManifest } from './manifest-loader'; - * - * const manifest = getPluginManifest(AnalyticsPlugin); - * console.log('Required resources:', manifest.resources.required); - * ``` + * @returns The validated, normalized plugin manifest + * @throws {ConfigurationError} If the manifest is missing, invalid, or has invalid resource type/permission */ export function getPluginManifest(plugin: PluginConstructor): PluginManifest { const pluginName = plugin.name || "unknown"; - try { - // Check for static manifest property - if (!plugin.manifest) { - throw new ConfigurationError( - `Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`, - ); - } - - // Validate manifest structure - const manifest = plugin.manifest; - - if (!manifest.name || typeof manifest.name !== "string") { - throw new ConfigurationError( - `Plugin ${pluginName} manifest has missing or invalid 'name' field`, - ); - } - - if (!manifest.displayName || typeof manifest.displayName !== "string") { - throw new ConfigurationError( - `Plugin ${manifest.name} manifest has missing or invalid 'displayName' field`, - ); - } - - if (!manifest.description || typeof manifest.description !== "string") { - throw new ConfigurationError( - `Plugin ${manifest.name} manifest has missing or invalid 'description' field`, - ); - } - - if (!manifest.resources) { - throw new ConfigurationError( - `Plugin ${manifest.name} manifest is missing 'resources' field`, - ); - } - - if (!Array.isArray(manifest.resources.required)) { - throw new ConfigurationError( - `Plugin ${manifest.name} manifest has invalid 'resources.required' field (expected array)`, - ); - } - - if ( - manifest.resources.optional && - !Array.isArray(manifest.resources.optional) - ) { - throw new ConfigurationError( - `Plugin ${manifest.name} manifest has invalid 'resources.optional' field (expected array)`, - ); - } - - logger.debug( - "Loaded manifest for plugin %s: %d required resources, %d optional resources", - manifest.name, - manifest.resources.required.length, - manifest.resources.optional?.length || 0, + if (!plugin.manifest) { + throw new ConfigurationError( + `Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`, + ); + } + + const raw = plugin.manifest; + + if (!raw.name || typeof raw.name !== "string") { + throw new ConfigurationError( + `Plugin ${pluginName} manifest has missing or invalid 'name' field`, + ); + } + + if (!raw.displayName || typeof raw.displayName !== "string") { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has missing or invalid 'displayName' field`, + ); + } + + if (!raw.description || typeof raw.description !== "string") { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has missing or invalid 'description' field`, ); + } + + if (!raw.resources) { + throw new ConfigurationError( + `Plugin ${raw.name} manifest is missing 'resources' field`, + ); + } - // Cast to appkit PluginManifest type (structurally compatible, just more specific types) - return manifest as unknown as PluginManifest; - } catch (error) { - if (error instanceof ConfigurationError) { - throw error; - } + if (!Array.isArray(raw.resources.required)) { throw new ConfigurationError( - `Error loading manifest from plugin ${pluginName}: ${error}`, + `Plugin ${raw.name} manifest has invalid 'resources.required' field (expected array)`, ); } + + if ( + raw.resources.optional !== undefined && + !Array.isArray(raw.resources.optional) + ) { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has invalid 'resources.optional' field (expected array)`, + ); + } + + const required = raw.resources.required.map((r) => { + const norm = normalizeResource(r as LooseResource); + const { required: _, ...rest } = norm; + return rest; + }); + const optional = (raw.resources.optional || []).map((r) => { + const norm = normalizeResource(r as LooseResource); + const { required: _, ...rest } = norm; + return rest; + }); + + logger.debug( + "Loaded manifest for plugin %s: %d required resources, %d optional resources", + raw.name, + required.length, + optional.length, + ); + + return { + ...raw, + resources: { required, optional }, + }; } /** diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts index be1a88f8..2fca1a02 100644 --- a/packages/appkit/src/registry/resource-registry.ts +++ b/packages/appkit/src/registry/resource-registry.ts @@ -1,9 +1,13 @@ /** - * Resource Registry Singleton + * Resource Registry * * Central registry that tracks all resource requirements across all plugins. - * Provides global visibility into Databricks resources needed by the application - * and handles deduplication when multiple plugins require the same resource. + * Provides visibility into Databricks resources needed by the application + * and handles deduplication when multiple plugins require the same resource + * (dedup key: type + resourceKey). + * + * Use `new ResourceRegistry()` for instance-scoped usage (e.g. createApp). + * getInstance() / resetInstance() remain for backward compatibility in tests. */ import type { BasePluginConfig, PluginConstructor, PluginData } from "shared"; @@ -16,87 +20,54 @@ import type { ResourceRequirement, ValidationResult, } from "./types"; +import { PERMISSION_HIERARCHY_BY_TYPE, type ResourceType } from "./types"; const logger = createLogger("resource-registry"); /** - * Permission hierarchy for merging logic. - * Higher index = more permissive. + * Dedup key for registry: type + resourceKey (machine-stable). + * alias is for UI/display only. */ -const PERMISSION_HIERARCHY: ResourcePermission[] = [ - "CAN_VIEW", - "READ", - "CAN_USE", - "WRITE", - "EXECUTE", - "CAN_MANAGE", -]; +function getDedupKey(type: string, resourceKey: string): string { + return `${type}:${resourceKey}`; +} /** - * Returns the most permissive permission between two permissions. + * Returns the most permissive permission for a given resource type. + * Uses per-type hierarchy; unknown permissions are treated as least permissive. */ function getMostPermissivePermission( + resourceType: ResourceType, p1: ResourcePermission, p2: ResourcePermission, ): ResourcePermission { - const index1 = PERMISSION_HIERARCHY.indexOf(p1); - const index2 = PERMISSION_HIERARCHY.indexOf(p2); + const hierarchy = PERMISSION_HIERARCHY_BY_TYPE[resourceType as ResourceType]; + const index1 = hierarchy?.indexOf(p1) ?? -1; + const index2 = hierarchy?.indexOf(p2) ?? -1; return index1 > index2 ? p1 : p2; } -/** - * Generates a unique key for a resource based on type and alias. - */ -function getResourceKey(type: string, alias: string): string { - return `${type}:${alias}`; -} - /** * Central registry for tracking plugin resource requirements. - * Implements singleton pattern to ensure a single source of truth. + * Deduplication uses type + resourceKey (machine-stable); alias is for display only. */ export class ResourceRegistry { - private static instance: ResourceRegistry | null = null; private resources: Map = new Map(); - /** - * Private constructor to enforce singleton pattern. - */ - private constructor() {} - - /** - * Gets the singleton instance of the ResourceRegistry. - * Creates a new instance if one doesn't exist. - */ - public static getInstance(): ResourceRegistry { - if (!ResourceRegistry.instance) { - ResourceRegistry.instance = new ResourceRegistry(); - } - return ResourceRegistry.instance; - } - - /** - * Resets the singleton instance. - * Primarily used for testing to ensure clean state between tests. - */ - public static resetInstance(): void { - ResourceRegistry.instance = null; - } - /** * Registers a resource requirement for a plugin. - * If a resource with the same type+alias already exists, merges them: + * If a resource with the same type+resourceKey already exists, merges them: * - Combines plugin names (comma-separated) - * - Uses the most permissive permission + * - Uses the most permissive permission (per-type hierarchy) * - Marks as required if any plugin requires it * - Combines descriptions if they differ - * - Keeps the env variable (or merges if they differ) + * - Merges fields; warns when same field name uses different env vars * * @param plugin - Name of the plugin registering the resource * @param resource - Resource requirement specification */ public register(plugin: string, resource: ResourceRequirement): void { - const key = getResourceKey(resource.type, resource.alias); + const key = getDedupKey(resource.type, resource.resourceKey); const existing = this.resources.get(key); if (existing) { @@ -117,13 +88,10 @@ export class ResourceRegistry { /** * Collects and registers resource requirements from an array of plugins. - * For each plugin, loads its manifest to discover static resource declarations, - * then checks for runtime resource requirements via `getResourceRequirements()`. - * - * Plugins without manifests are silently skipped (allowed for legacy plugins - * or plugins that don't declare resources). + * For each plugin, loads its manifest (required) and runtime resource requirements. * * @param rawPlugins - Array of plugin data entries from createApp configuration + * @throws {ConfigurationError} If any plugin is missing a manifest or manifest is invalid */ public collectResources( rawPlugins: PluginData[], @@ -132,46 +100,33 @@ export class ResourceRegistry { if (!pluginData?.plugin) continue; const pluginName = pluginData.name; + const manifest = getPluginManifest(pluginData.plugin); - try { - const manifest = getPluginManifest(pluginData.plugin); - - // Register required resources - for (const resource of manifest.resources.required) { - this.register(pluginName, { ...resource, required: true }); - } - - // Register optional resources - for (const resource of manifest.resources.optional || []) { - this.register(pluginName, { ...resource, required: false }); - } + // Register required resources + for (const resource of manifest.resources.required) { + this.register(pluginName, { ...resource, required: true }); + } - // Check for runtime resource requirements - if (typeof pluginData.plugin.getResourceRequirements === "function") { - const runtimeResources = pluginData.plugin.getResourceRequirements( - pluginData.config as BasePluginConfig, - ); - for (const resource of runtimeResources) { - // Cast from shared's ResourceRequirement to registry's ResourceRequirement - // The shared type has looser typing (string) vs registry (ResourceType enum) - this.register(pluginName, resource as ResourceRequirement); - } - } + // Register optional resources + for (const resource of manifest.resources.optional || []) { + this.register(pluginName, { ...resource, required: false }); + } - logger.debug( - "Collected resources from plugin %s: %d total", - pluginName, - this.getByPlugin(pluginName).length, - ); - } catch (error) { - // Plugin doesn't have a manifest - this is allowed for legacy plugins - // or plugins that don't declare resources - logger.debug( - "Plugin %s has no manifest or invalid manifest: %s", - pluginName, - error instanceof Error ? error.message : String(error), + // Check for runtime resource requirements + if (typeof pluginData.plugin.getResourceRequirements === "function") { + const runtimeResources = pluginData.plugin.getResourceRequirements( + pluginData.config as BasePluginConfig, ); + for (const resource of runtimeResources) { + this.register(pluginName, resource as ResourceRequirement); + } } + + logger.debug( + "Collected resources from plugin %s: %d total", + pluginName, + this.getByPlugin(pluginName).length, + ); } } @@ -196,8 +151,9 @@ export class ResourceRegistry { [newPlugin]: newResource.permission, }; - // Use the most permissive permission, but warn when escalating + // Use the most permissive permission for this resource type; warn when escalating const permission = getMostPermissivePermission( + existing.type as ResourceType, existing.permission, newResource.permission, ); @@ -207,7 +163,7 @@ export class ResourceRegistry { 'Resource %s:%s permission escalated from "%s" to "%s" due to plugin "%s" ' + "(previously requested by: %s). Review plugin permissions to ensure least-privilege.", existing.type, - existing.alias, + existing.resourceKey, existing.permission, permission, newPlugin, @@ -224,14 +180,35 @@ export class ResourceRegistry { newResource.description && newResource.description !== existing.description ) { - // Check if the new description is already included if (!existing.description.includes(newResource.description)) { description = `${existing.description}; ${newResource.description}`; } } - // Prefer existing fields when both have them (same type+alias) - const fields = existing.fields ?? newResource.fields; + // Merge fields: union of field names; warn when same field name uses different env + const fields = { ...(existing.fields ?? {}) }; + for (const [fieldName, newField] of Object.entries( + newResource.fields ?? {}, + )) { + const existingField = fields[fieldName]; + if (existingField) { + if (existingField.env !== newField.env) { + logger.warn( + 'Resource %s:%s field "%s": conflicting env vars "%s" (from %s) vs "%s" (from %s). Using first.', + existing.type, + existing.resourceKey, + fieldName, + existingField.env, + existing.plugin, + newField.env, + newPlugin, + ); + } + // keep existing + } else { + fields[fieldName] = newField; + } + } return { ...existing, @@ -255,15 +232,14 @@ export class ResourceRegistry { } /** - * Gets a specific resource by type and alias. + * Gets a specific resource by type and resourceKey (dedup key). * * @param type - Resource type - * @param alias - Resource alias + * @param resourceKey - Stable machine key (not alias; alias is for display only) * @returns The resource entry if found, undefined otherwise */ - public get(type: string, alias: string): ResourceEntry | undefined { - const key = getResourceKey(type, alias); - return this.resources.get(key); + public get(type: string, resourceKey: string): ResourceEntry | undefined { + return this.resources.get(getDedupKey(type, resourceKey)); } /** diff --git a/packages/appkit/src/registry/tests/resource-registry.test.ts b/packages/appkit/src/registry/tests/resource-registry.test.ts index 23b0b604..2a1e81dc 100644 --- a/packages/appkit/src/registry/tests/resource-registry.test.ts +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -1,19 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ResourceRegistry } from "../resource-registry"; import { ResourceType } from "../types"; describe("ResourceRegistry", () => { - beforeEach(() => { - ResourceRegistry.resetInstance(); - }); - - afterEach(() => { - ResourceRegistry.resetInstance(); - }); - describe("register and merge with fields", () => { it("should register a multi-field resource (database)", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.DATABASE, alias: "cache", @@ -48,7 +40,7 @@ describe("ResourceRegistry", () => { }); it("should merge resources and prefer existing fields", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("plugin-a", { type: ResourceType.SECRET, alias: "creds", @@ -84,7 +76,7 @@ describe("ResourceRegistry", () => { }); it("should merge single-value resources (fields with one key)", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("plugin-a", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -120,7 +112,7 @@ describe("ResourceRegistry", () => { const CACHE_DB = "DATABRICKS_CACHE_DB"; it("should resolve multi-field resource when all env vars are set", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.DATABASE, alias: "cache", @@ -157,7 +149,7 @@ describe("ResourceRegistry", () => { }); it("should mark multi-field resource missing when any env var is unset", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.DATABASE, alias: "cache", @@ -185,7 +177,7 @@ describe("ResourceRegistry", () => { }); it("should mark multi-field resource missing when only one env var is set", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.DATABASE, alias: "cache", @@ -213,7 +205,7 @@ describe("ResourceRegistry", () => { describe("permission escalation tracking", () => { it("should track permissionSources for a single plugin", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("plugin-a", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -229,7 +221,7 @@ describe("ResourceRegistry", () => { }); it("should track permissionSources when merging multiple plugins", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("plugin-a", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -258,7 +250,7 @@ describe("ResourceRegistry", () => { }); it("should warn when permission is escalated during merge", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); registry.register("plugin-a", { @@ -288,7 +280,7 @@ describe("ResourceRegistry", () => { }); it("should not escalate when permissions are identical", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("plugin-a", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -319,7 +311,7 @@ describe("ResourceRegistry", () => { describe("enforceValidation with APPKIT_STRICT_VALIDATION", () => { it("should throw in dev when APPKIT_STRICT_VALIDATION=true", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -344,7 +336,7 @@ describe("ResourceRegistry", () => { }); it("should only warn in dev when APPKIT_STRICT_VALIDATION is not set", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", @@ -398,7 +390,7 @@ describe("ResourceRegistry", () => { describe("formatMissingResources with fields", () => { it("should list field env vars for multi-field missing resources", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.SECRET, alias: "creds", @@ -424,7 +416,7 @@ describe("ResourceRegistry", () => { }); it("should list field env vars for single-value missing resources", () => { - const registry = ResourceRegistry.getInstance(); + const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index d1781ad1..bcbc8d34 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -113,6 +113,35 @@ export type ResourcePermission = | ExperimentPermission | AppPermission; +/** + * Permission hierarchy per resource type (weakest to strongest). + * Used to compare permissions when merging; higher index = more permissive. + * Unknown permissions are treated as less than any known permission. + */ +export const PERMISSION_HIERARCHY_BY_TYPE: Record< + ResourceType, + readonly ResourcePermission[] +> = { + [ResourceType.SECRET]: ["READ", "WRITE", "MANAGE"], + [ResourceType.JOB]: ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], + [ResourceType.SQL_WAREHOUSE]: ["CAN_USE", "CAN_MANAGE"], + [ResourceType.SERVING_ENDPOINT]: ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], + [ResourceType.VOLUME]: ["READ_VOLUME", "WRITE_VOLUME"], + [ResourceType.VECTOR_SEARCH_INDEX]: ["SELECT"], + [ResourceType.UC_FUNCTION]: ["EXECUTE"], + [ResourceType.UC_CONNECTION]: ["USE_CONNECTION"], + [ResourceType.DATABASE]: ["CAN_CONNECT_AND_CREATE"], + [ResourceType.GENIE_SPACE]: ["CAN_VIEW", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"], + [ResourceType.EXPERIMENT]: ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + [ResourceType.APP]: ["CAN_USE"], +} as const; + +/** Set of valid permissions per type (for validation). */ +export const PERMISSIONS_BY_TYPE: Record< + ResourceType, + readonly ResourcePermission[] +> = PERMISSION_HIERARCHY_BY_TYPE; + /** * Defines a single field for a resource. Each field has its own environment variable and optional description. * Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index aa3fd137..8f8c9feb 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -220,13 +220,13 @@ "alias": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin (UI/display)", + "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", "examples": ["SQL Warehouse", "Secret", "Vector search index"] }, "resourceKey": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", "examples": ["sql-warehouse", "database", "secret"] }, "description": { From 465f50a3a053c8012295afb6ad4aa680806cc58f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 12 Feb 2026 15:00:10 +0100 Subject: [PATCH 9/9] chore: fixup --- .../appkit/src/core/tests/databricks.test.ts | 86 +++ .../src/registry/tests/integration.test.ts | 2 +- .../registry/tests/resource-registry.test.ts | 562 ++++++++++++++---- .../src/cli/commands/plugins-sync.test.ts | 170 ++++++ .../shared/src/cli/commands/plugins-sync.ts | 3 + 5 files changed, 709 insertions(+), 114 deletions(-) create mode 100644 packages/shared/src/cli/commands/plugins-sync.test.ts diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index a50511ac..41aa73b0 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -3,6 +3,7 @@ import type { BasePlugin } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; import type { PluginManifest } from "../../registry/types"; +import { ResourceType } from "../../registry/types"; import { AppKit, createApp } from "../appkit"; // Generic test manifest for test plugins @@ -488,6 +489,91 @@ describe("AppKit", () => { }); }); + describe("createApp resource validation (collectResources + enforceValidation)", () => { + test("should throw in production when required resource env is missing", async () => { + const PluginWithRequiredResource = class extends CoreTestPlugin { + static manifest: PluginManifest = { + name: "withResource", + displayName: "With Resource", + description: "Plugin with required warehouse", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + }; + + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const pluginData = [ + { + plugin: PluginWithRequiredResource, + config: {}, + name: "withResource", + }, + ]; + await expect(createApp({ plugins: pluginData })).rejects.toThrow(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + test("should succeed when required resource env is set", async () => { + const PluginWithRequiredResource = class extends CoreTestPlugin { + static manifest: PluginManifest = { + name: "withResource", + displayName: "With Resource", + description: "Plugin with required warehouse", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + }; + + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.DATABRICKS_WAREHOUSE_ID = "wh-123"; + try { + const pluginData = [ + { + plugin: PluginWithRequiredResource, + config: {}, + name: "withResource", + }, + ]; + const instance = await createApp({ plugins: pluginData }); + expect(instance).toBeDefined(); + expect((instance as any).withResource).toBeDefined(); + } finally { + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + }); + describe("SDK context binding", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts index 6b328af2..cbc66005 100644 --- a/packages/appkit/src/registry/tests/integration.test.ts +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -27,7 +27,7 @@ describe("Manifest Loader Integration", () => { expect(manifest?.displayName).toBe("Analytics Plugin"); }); - it("should require SQL Warehouse and list optional cache database", () => { + it("should require SQL Warehouse (no optional resources in manifest)", () => { const resources = getResourceRequirements(AnalyticsPlugin); expect(resources).toHaveLength(1); diff --git a/packages/appkit/src/registry/tests/resource-registry.test.ts b/packages/appkit/src/registry/tests/resource-registry.test.ts index 2a1e81dc..7d5598d5 100644 --- a/packages/appkit/src/registry/tests/resource-registry.test.ts +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -1,5 +1,7 @@ +import type { PluginConstructor, PluginData } from "shared"; import { describe, expect, it, vi } from "vitest"; import { ResourceRegistry } from "../resource-registry"; +import type { ResourceRequirement } from "../types"; import { ResourceType } from "../types"; describe("ResourceRegistry", () => { @@ -112,25 +114,25 @@ describe("ResourceRegistry", () => { const CACHE_DB = "DATABRICKS_CACHE_DB"; it("should resolve multi-field resource when all env vars are set", () => { - const registry = new ResourceRegistry(); - registry.register("analytics", { - type: ResourceType.DATABASE, - alias: "cache", - resourceKey: "cache", - description: "Cache database", - permission: "CAN_CONNECT_AND_CREATE", - required: true, - fields: { - instance_name: { env: CACHE_INSTANCE }, - database_name: { env: CACHE_DB }, - }, - }); - - const orig = process.env[CACHE_INSTANCE]; - const origDb = process.env[CACHE_DB]; + const prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; process.env[CACHE_INSTANCE] = "my-instance"; process.env[CACHE_DB] = "my_db"; try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + const result = registry.validate(); expect(result.valid).toBe(true); expect(result.missing).toHaveLength(0); @@ -141,65 +143,81 @@ describe("ResourceRegistry", () => { database_name: "my_db", }); } finally { - if (orig !== undefined) process.env[CACHE_INSTANCE] = orig; + if (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; else delete process.env[CACHE_INSTANCE]; - if (origDb !== undefined) process.env[CACHE_DB] = origDb; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; else delete process.env[CACHE_DB]; } }); it("should mark multi-field resource missing when any env var is unset", () => { - const registry = new ResourceRegistry(); - registry.register("analytics", { - type: ResourceType.DATABASE, - alias: "cache", - resourceKey: "cache", - description: "Cache database", - permission: "CAN_CONNECT_AND_CREATE", - required: true, - fields: { - instance_name: { env: CACHE_INSTANCE }, - database_name: { env: CACHE_DB }, - }, - }); - + const prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; delete process.env[CACHE_INSTANCE]; delete process.env[CACHE_DB]; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); - const result = registry.validate(); - expect(result.valid).toBe(false); - expect(result.missing).toHaveLength(1); - expect(result.missing[0].type).toBe("database"); - expect(result.missing[0].alias).toBe("cache"); - const entry = registry.get("database", "cache"); - expect(entry?.resolved).toBe(false); - expect(entry?.values).toBeUndefined(); + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + expect(result.missing[0].type).toBe("database"); + expect(result.missing[0].alias).toBe("cache"); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toBeUndefined(); + } finally { + if (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; + else delete process.env[CACHE_INSTANCE]; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; + else delete process.env[CACHE_DB]; + } }); it("should mark multi-field resource missing when only one env var is set", () => { - const registry = new ResourceRegistry(); - registry.register("analytics", { - type: ResourceType.DATABASE, - alias: "cache", - resourceKey: "cache", - description: "Cache database", - permission: "CAN_CONNECT_AND_CREATE", - required: true, - fields: { - instance_name: { env: CACHE_INSTANCE }, - database_name: { env: CACHE_DB }, - }, - }); - + const prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; process.env[CACHE_INSTANCE] = "my-instance"; delete process.env[CACHE_DB]; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); - const result = registry.validate(); - expect(result.valid).toBe(false); - expect(result.missing).toHaveLength(1); - const entry = registry.get("database", "cache"); - expect(entry?.resolved).toBe(false); - expect(entry?.values).toEqual({ instance_name: "my-instance" }); + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toEqual({ instance_name: "my-instance" }); + } finally { + if (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; + else delete process.env[CACHE_INSTANCE]; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; + else delete process.env[CACHE_DB]; + } }); }); @@ -309,53 +327,110 @@ describe("ResourceRegistry", () => { }); }); - describe("enforceValidation with APPKIT_STRICT_VALIDATION", () => { - it("should throw in dev when APPKIT_STRICT_VALIDATION=true", () => { - const registry = new ResourceRegistry(); - registry.register("analytics", { - type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", - resourceKey: "warehouse", - description: "Warehouse", - permission: "CAN_USE", - required: true, - fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, - }); + describe("enforceValidation", () => { + it("should throw in production when required resources are missing", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + expect(() => registry.enforceValidation()).toThrow(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); - const origNodeEnv = process.env.NODE_ENV; - const origStrict = process.env.APPKIT_STRICT_VALIDATION; + it("should throw in dev when APPKIT_STRICT_VALIDATION=true and resources missing", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevStrict = process.env.APPKIT_STRICT_VALIDATION; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; process.env.NODE_ENV = "development"; process.env.APPKIT_STRICT_VALIDATION = "true"; + delete process.env.DATABRICKS_WAREHOUSE_ID; try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); expect(() => registry.enforceValidation()).toThrow(); } finally { - process.env.NODE_ENV = origNodeEnv; - process.env.APPKIT_STRICT_VALIDATION = origStrict ?? ""; + process.env.NODE_ENV = prevNodeEnv; + process.env.APPKIT_STRICT_VALIDATION = prevStrict ?? ""; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; } }); it("should only warn in dev when APPKIT_STRICT_VALIDATION is not set", () => { - const registry = new ResourceRegistry(); - registry.register("analytics", { - type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", - resourceKey: "warehouse", - description: "Warehouse", - permission: "CAN_USE", - required: true, - fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, - }); - delete process.env.DATABRICKS_WAREHOUSE_ID; - - const origNodeEnv = process.env.NODE_ENV; - delete process.env.APPKIT_STRICT_VALIDATION; + const prevNodeEnv = process.env.NODE_ENV; + const prevStrict = process.env.APPKIT_STRICT_VALIDATION; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; process.env.NODE_ENV = "development"; + delete process.env.APPKIT_STRICT_VALIDATION; + delete process.env.DATABRICKS_WAREHOUSE_ID; try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); const result = registry.enforceValidation(); expect(result.valid).toBe(false); } finally { - process.env.NODE_ENV = origNodeEnv; + process.env.NODE_ENV = prevNodeEnv; + if (prevStrict !== undefined) + process.env.APPKIT_STRICT_VALIDATION = prevStrict; + else delete process.env.APPKIT_STRICT_VALIDATION; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + it("should not throw in production when all required resources are set", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; + process.env.DATABRICKS_WAREHOUSE_ID = "wh-123"; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + const result = registry.enforceValidation(); + expect(result.valid).toBe(true); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; } }); }); @@ -390,49 +465,310 @@ describe("ResourceRegistry", () => { describe("formatMissingResources with fields", () => { it("should list field env vars for multi-field missing resources", () => { + const prevScope = process.env.SECRET_SCOPE; + const prevKey = process.env.SECRET_KEY; + delete process.env.SECRET_SCOPE; + delete process.env.SECRET_KEY; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE" }, + key: { env: "SECRET_KEY" }, + }, + }); + + const result = registry.validate(); + expect(result.valid).toBe(false); + + const formatted = ResourceRegistry.formatMissingResources( + result.missing, + ); + expect(formatted).toContain("secret:creds"); + expect(formatted).toContain("SECRET_SCOPE"); + expect(formatted).toContain("SECRET_KEY"); + } finally { + if (prevScope !== undefined) process.env.SECRET_SCOPE = prevScope; + else delete process.env.SECRET_SCOPE; + if (prevKey !== undefined) process.env.SECRET_KEY = prevKey; + else delete process.env.SECRET_KEY; + } + }); + + it("should list field env vars for single-value missing resources", () => { + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + const result = registry.validate(); + const formatted = ResourceRegistry.formatMissingResources( + result.missing, + ); + expect(formatted).toContain("DATABRICKS_WAREHOUSE_ID"); + } finally { + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + }); + + describe("collectResources with getResourceRequirements", () => { + it("should register runtime resources from getResourceRequirements(config)", () => { + interface Config { + enableCache?: boolean; + } + const PluginWithRuntimeRequirements = class { + static manifest = { + name: "with-runtime", + displayName: "With Runtime", + description: "Plugin with runtime resources", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE" as const, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + static getResourceRequirements(config: Config): ResourceRequirement[] { + const base: ResourceRequirement[] = [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + required: true, + }, + ]; + if (config.enableCache) { + base.push({ + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache DB", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + instance_name: { env: "CACHE_INSTANCE" }, + database_name: { env: "CACHE_DB" }, + }, + required: true, + }); + } + return base; + } + }; + const registry = new ResourceRegistry(); - registry.register("analytics", { + const rawPlugins: PluginData[] = [ + { + name: "withRuntime", + plugin: PluginWithRuntimeRequirements as unknown as PluginConstructor, + config: { enableCache: true }, + }, + ]; + registry.collectResources(rawPlugins); + + expect(registry.size()).toBe(2); + expect(registry.get("sql_warehouse", "warehouse")).toBeDefined(); + expect(registry.get("database", "cache")).toBeDefined(); + expect(registry.getByPlugin("withRuntime")).toHaveLength(2); + }); + }); + + describe("mergeResources edge cases", () => { + it("should merge when second plugin adds new field names (union of fields)", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { type: ResourceType.SECRET, alias: "creds", resourceKey: "creds", - description: "Credentials", + description: "Creds", permission: "READ", required: true, fields: { - scope: { env: "SECRET_SCOPE" }, - key: { env: "SECRET_KEY" }, + scope: { env: "SCOPE_A", description: "Scope" }, + key: { env: "KEY_A", description: "Key" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Creds", + permission: "READ", + required: false, + fields: { + scope: { env: "SCOPE_B" }, + key: { env: "KEY_B" }, + extra_field: { env: "EXTRA_B", description: "Extra" }, }, }); - delete process.env.SECRET_SCOPE; - delete process.env.SECRET_KEY; - const result = registry.validate(); - expect(result.valid).toBe(false); + const entry = registry.get("secret", "creds"); + expect(entry?.fields.scope.env).toBe("SCOPE_A"); + expect(entry?.fields.key.env).toBe("KEY_A"); + expect(entry?.fields.extra_field?.env).toBe("EXTRA_B"); + }); + + it("should treat unlisted permission as least permissive when merging", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "UNKNOWN_PERMISSION" as any, + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); - const formatted = ResourceRegistry.formatMissingResources(result.missing); - expect(formatted).toContain("secret:creds"); - expect(formatted).toContain("SECRET_SCOPE"); - expect(formatted).toContain("SECRET_KEY"); + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_USE"); }); + }); - it("should list field env vars for single-value missing resources", () => { + describe("registry accessors", () => { + it("getByPlugin returns only resources for that plugin", () => { const registry = new ResourceRegistry(); registry.register("analytics", { type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", + alias: "wh", resourceKey: "warehouse", - description: "Warehouse", + description: "WH", permission: "CAN_USE", required: true, - fields: { - id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, - }, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("server", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_APP_ID" } }, }); - delete process.env.DATABRICKS_WAREHOUSE_ID; - const result = registry.validate(); - const formatted = ResourceRegistry.formatMissingResources(result.missing); - expect(formatted).toContain("DATABRICKS_WAREHOUSE_ID"); + const byAnalytics = registry.getByPlugin("analytics"); + const byServer = registry.getByPlugin("server"); + expect(byAnalytics).toHaveLength(1); + expect(byServer).toHaveLength(1); + expect(byAnalytics[0].type).toBe("sql_warehouse"); + expect(byServer[0].type).toBe("app"); + }); + + it("getRequired and getOptional filter by required flag", () => { + const registry = new ResourceRegistry(); + registry.register("p", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + registry.register("p", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: false, + fields: { id: { env: "APP_ID" } }, + }); + + expect(registry.getRequired()).toHaveLength(1); + expect(registry.getOptional()).toHaveLength(1); + expect(registry.getRequired()[0].resourceKey).toBe("warehouse"); + expect(registry.getOptional()[0].resourceKey).toBe("app"); + }); + + it("size returns count of unique resources (by type+resourceKey)", () => { + const registry = new ResourceRegistry(); + expect(registry.size()).toBe(0); + registry.register("a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.register("b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: false, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.register("b", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: true, + fields: { id: { env: "APP_ID" } }, + }); + expect(registry.size()).toBe(2); + }); + + it("clear removes all resources", () => { + const registry = new ResourceRegistry(); + registry.register("a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.clear(); + expect(registry.size()).toBe(0); + expect(registry.get("sql_warehouse", "warehouse")).toBeUndefined(); }); }); }); diff --git a/packages/shared/src/cli/commands/plugins-sync.test.ts b/packages/shared/src/cli/commands/plugins-sync.test.ts new file mode 100644 index 00000000..592ff163 --- /dev/null +++ b/packages/shared/src/cli/commands/plugins-sync.test.ts @@ -0,0 +1,170 @@ +import path from "node:path"; +import { Lang, parse } from "@ast-grep/napi"; +import { describe, expect, it } from "vitest"; +import { + isWithinDirectory, + parseImports, + parsePluginUsages, +} from "./plugins-sync"; + +describe("plugins-sync", () => { + describe("isWithinDirectory", () => { + it("returns true when filePath equals boundary", () => { + const dir = path.resolve("/project/root"); + expect(isWithinDirectory(dir, dir)).toBe(true); + }); + + it("returns true when filePath is inside boundary", () => { + expect( + isWithinDirectory("/project/root/sub/file.ts", "/project/root"), + ).toBe(true); + expect(isWithinDirectory("/project/root/foo", "/project/root")).toBe( + true, + ); + }); + + it("returns false when filePath escapes boundary", () => { + expect( + isWithinDirectory("/project/root/../etc/passwd", "/project/root"), + ).toBe(false); + expect(isWithinDirectory("/other/file.ts", "/project/root")).toBe(false); + }); + + it("returns false when path is sibling (prefix edge case)", () => { + const root = path.resolve("/project/root"); + const sibling = path.resolve("/project/root-bar/file.ts"); + expect(isWithinDirectory(sibling, root)).toBe(false); + }); + + it("handles relative paths by resolving them", () => { + const cwd = process.cwd(); + expect(isWithinDirectory("package.json", cwd)).toBe(true); + }); + }); + + describe("parseImports", () => { + function parseCode(code: string) { + const ast = parse(Lang.TypeScript, code); + return parseImports(ast.root()); + } + + it("extracts named imports from a single statement", () => { + const imports = parseCode( + `import { createApp, server, analytics } from "@databricks/appkit";`, + ); + expect(imports).toHaveLength(3); + expect(imports.map((i) => i.name)).toEqual([ + "createApp", + "server", + "analytics", + ]); + expect(imports.map((i) => i.originalName)).toEqual([ + "createApp", + "server", + "analytics", + ]); + expect(imports[0].source).toBe("@databricks/appkit"); + }); + + it("extracts aliased imports", () => { + const imports = parseCode( + `import { createApp as initApp, server as srv } from "@databricks/appkit";`, + ); + expect(imports).toHaveLength(2); + expect(imports[0]).toEqual({ + name: "initApp", + originalName: "createApp", + source: "@databricks/appkit", + }); + expect(imports[1]).toEqual({ + name: "srv", + originalName: "server", + source: "@databricks/appkit", + }); + }); + + it("extracts relative imports", () => { + const imports = parseCode( + `import { myPlugin } from "./plugins/my-plugin";`, + ); + expect(imports).toHaveLength(1); + expect(imports[0].name).toBe("myPlugin"); + expect(imports[0].source).toBe("./plugins/my-plugin"); + }); + + it("handles double-quoted specifiers", () => { + const imports = parseCode(`import { foo } from "@databricks/appkit";`); + expect(imports[0].source).toBe("@databricks/appkit"); + }); + + it("returns empty array when no named imports", () => { + const imports = parseCode(`const x = 1;`); + expect(imports).toHaveLength(0); + }); + + it("handles multiple import statements", () => { + const imports = parseCode(` + import { createApp } from "@databricks/appkit"; + import { myPlugin } from "./my-plugin"; + `); + expect(imports).toHaveLength(2); + expect(imports[0].source).toBe("@databricks/appkit"); + expect(imports[1].source).toBe("./my-plugin"); + }); + }); + + describe("parsePluginUsages", () => { + function parseCode(code: string) { + const ast = parse(Lang.TypeScript, code); + return parsePluginUsages(ast.root()); + } + + it("extracts plugin names used in createApp plugins array", () => { + const used = parseCode(` + createApp({ + plugins: [ + server(), + analytics(), + ], + }); + `); + expect(Array.from(used)).toEqual( + expect.arrayContaining(["server", "analytics"]), + ); + expect(used.size).toBe(2); + }); + + it("ignores non-plugin call expressions in the same object", () => { + const used = parseCode(` + createApp({ + plugins: [server()], + telemetry: { enabled: true }, + }); + `); + expect(Array.from(used)).toEqual(["server"]); + }); + + it("returns empty set when no plugins key with array", () => { + const used = parseCode(`createApp({});`); + expect(used.size).toBe(0); + }); + + it("returns empty set when plugins is not an array of calls", () => { + const used = parseCode(` + createApp({ + plugins: [], + }); + `); + expect(used.size).toBe(0); + }); + + it("extracts single plugin usage", () => { + const used = parseCode(` + createApp({ + plugins: [server()], + }); + `); + expect(Array.from(used)).toEqual(["server"]); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugins-sync.ts index 865cca85..ef4cbc2e 100644 --- a/packages/shared/src/cli/commands/plugins-sync.ts +++ b/packages/shared/src/cli/commands/plugins-sync.ts @@ -627,6 +627,9 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { } } +/** Exported for testing: path boundary check, AST parsing. */ +export { isWithinDirectory, parseImports, parsePluginUsages }; + export const pluginsSyncCommand = new Command("sync") .description( "Sync plugin manifests from installed packages into appkit.plugins.json",