diff --git a/CLAUDE.md b/CLAUDE.md index 75df02b5..25f598a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,11 +174,6 @@ import { Plugin, toPlugin } from '@databricks/appkit'; class MyPlugin extends Plugin { name: string = "myPlugin"; - // Validate required environment variables - validateEnv() { - // Check process.env for required vars - } - // Async initialization async setup() { // Initialize resources 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 c34588e3..3c552aa3 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -9,6 +9,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"; @@ -70,8 +71,6 @@ export class AppKit { this.#pluginInstances[name] = pluginInstance; - pluginInstance.validateEnv(); - this.#setupPromises.push(pluginInstance.setup()); const self = this; @@ -152,6 +151,13 @@ export class AppKit { await ServiceContext.initialize(); 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 6378b037..a10e495a 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -712,448 +712,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", @@ -5887,9 +5445,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": { @@ -8720,49 +8278,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", @@ -11582,12 +11097,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", @@ -11616,9 +11131,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": { @@ -11945,9 +11460,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);