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 a741189e..64de5830 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -1,6 +1,88 @@ # Abstract Class: Plugin\ -Base abstract class for creating AppKit plugins +Base abstract class for creating AppKit plugins. + +All plugins must declare a static `manifest` property with their metadata +and resource requirements. The manifest defines: +- `required` resources: Always needed for the plugin to function +- `optional` resources: May be needed depending on plugin configuration + +## 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'; + +const myManifest: PluginManifest = { + name: 'myPlugin', + displayName: 'My Plugin', + description: 'Does something awesome', + resources: { + required: [ + { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + ], + optional: [] + } +}; + +class MyPlugin extends Plugin { + static manifest = myManifest; + name = 'myPlugin'; +} +``` + +```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'; + + // 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; + } +} +``` ## Type Parameters @@ -64,14 +146,6 @@ protected devFileReader: DevFileReader; *** -### envVars - -```ts -abstract protected envVars: string[]; -``` - -*** - ### isReady ```ts @@ -86,6 +160,8 @@ protected isReady: boolean = false; name: string; ``` +Plugin name identifier. + #### Implementation of ```ts @@ -116,6 +192,11 @@ protected telemetry: ITelemetry; static phase: PluginPhase = "normal"; ``` +Plugin initialization phase. +- 'core': Initialized first (e.g., config plugins) +- 'normal': Initialized second (most plugins) +- 'deferred': Initialized last (e.g., server plugin) + ## Methods ### abortActiveOperations() @@ -347,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..e8c4d841 --- /dev/null +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -0,0 +1,294 @@ +# Class: ResourceRegistry + +Central registry for tracking plugin resource requirements. +Deduplication uses type + resourceKey (machine-stable); alias is for display only. + +## Constructors + +### Constructor + +```ts +new ResourceRegistry(): ResourceRegistry; +``` + +#### Returns + +`ResourceRegistry` + +## 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 (required) and runtime resource requirements. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `rawPlugins` | `PluginData`\<`PluginConstructor`, `unknown`, `string`\>[] | Array of plugin data entries from createApp configuration | + +#### Returns + +`void` + +#### Throws + +If any plugin is missing a manifest or manifest is invalid + +*** + +### 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, unless + `APPKIT_STRICT_VALIDATION=true` is set, in which case throws like production. +- When all resources are valid: logs a debug message with the count. + +#### Returns + +[`ValidationResult`](Interface.ValidationResult.md) + +ValidationResult with validity status, missing resources, and all resources + +#### Throws + +In production when required resources are missing, or in dev when APPKIT_STRICT_VALIDATION=true + +*** + +### get() + +```ts +get(type: string, resourceKey: string): ResourceEntry | undefined; +``` + +Gets a specific resource by type and resourceKey (dedup key). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `type` | `string` | Resource type | +| `resourceKey` | `string` | Stable machine key (not alias; alias is for display only) | + +#### 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+resourceKey already exists, merges them: +- Combines plugin names (comma-separated) +- Uses the most permissive permission (per-type hierarchy) +- Marks as required if any plugin requires it +- Combines descriptions if they differ +- Merges fields; warns when same field name uses different env vars + +#### 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))); +} +``` + +*** + +### formatDevWarningBanner() + +```ts +static formatDevWarningBanner(missing: ResourceEntry[]): string; +``` + +Formats a highly visible warning banner for dev-mode missing resources. +Uses box drawing to ensure the message is impossible to miss in scrolling logs. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `missing` | [`ResourceEntry`](Interface.ResourceEntry.md)[] | Array of missing resource entries | + +#### Returns + +`string` + +Formatted banner string + +*** + +### formatMissingResources() + +```ts +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 diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md new file mode 100644 index 00000000..bb2d12db --- /dev/null +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -0,0 +1,124 @@ +# Enumeration: ResourceType + +Supported resource types that plugins can depend on. +Each type has its own set of valid permissions. + +## Enumeration Members + +### APP + +```ts +APP: "app"; +``` + +Databricks App dependency + +*** + +### DATABASE + +```ts +DATABASE: "database"; +``` + +Database (Lakebase) for persistent storage + +*** + +### EXPERIMENT + +```ts +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 + +*** + +### SERVING\_ENDPOINT + +```ts +SERVING_ENDPOINT: "serving_endpoint"; +``` + +Model serving endpoint for ML inference + +*** + +### SQL\_WAREHOUSE + +```ts +SQL_WAREHOUSE: "sql_warehouse"; +``` + +Databricks SQL Warehouse for query execution + +*** + +### UC\_CONNECTION + +```ts +UC_CONNECTION: "uc_connection"; +``` + +Unity Catalog Connection for external data sources + +*** + +### UC\_FUNCTION + +```ts +UC_FUNCTION: "uc_function"; +``` + +Unity Catalog Function + +*** + +### VECTOR\_SEARCH\_INDEX + +```ts +VECTOR_SEARCH_INDEX: "vector_search_index"; +``` + +Vector Search Index for similarity search + +*** + +### VOLUME + +```ts +VOLUME: "volume"; +``` + +Unity Catalog Volume for file storage diff --git a/docs/docs/api/appkit/Function.getPluginManifest.md b/docs/docs/api/appkit/Function.getPluginManifest.md new file mode 100644 index 00000000..1d5a3e01 --- /dev/null +++ b/docs/docs/api/appkit/Function.getPluginManifest.md @@ -0,0 +1,24 @@ +# Function: getPluginManifest() + +```ts +function getPluginManifest(plugin: PluginConstructor): PluginManifest; +``` + +Loads and validates the manifest from a plugin constructor. +Normalizes string type/permission to strict ResourceType/ResourcePermission. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +[`PluginManifest`](Interface.PluginManifest.md) + +The validated, normalized plugin manifest + +## Throws + +If the manifest is missing, invalid, or has invalid resource type/permission diff --git a/docs/docs/api/appkit/Function.getResourceRequirements.md b/docs/docs/api/appkit/Function.getResourceRequirements.md new file mode 100644 index 00000000..8feb69a5 --- /dev/null +++ b/docs/docs/api/appkit/Function.getResourceRequirements.md @@ -0,0 +1,41 @@ +# Function: getResourceRequirements() + +```ts +function getResourceRequirements(plugin: PluginConstructor): { + alias: string; + description: string; + fields: Record; + permission: ResourcePermission; + required: boolean; + resourceKey: string; + type: ResourceType; +}[]; +``` + +Gets the resource requirements from a plugin's manifest. + +Combines required and optional resources into a single array with the +`required` flag set appropriately. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +Combined array of required and optional resources + +## Throws + +If the plugin manifest is missing or invalid + +## Example + +```typescript +const resources = getResourceRequirements(AnalyticsPlugin); +for (const resource of resources) { + console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); +} +``` diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md new file mode 100644 index 00000000..8a376ac5 --- /dev/null +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -0,0 +1,124 @@ +# Interface: PluginManifest + +Plugin manifest that declares metadata and resource requirements. +Attached to plugin classes as a static property. + +## Properties + +### author? + +```ts +optional author: string; +``` + +Optional metadata for community plugins + +*** + +### config? + +```ts +optional config: { + schema: JSONSchema7; +}; +``` + +Configuration schema for the plugin. +Defines the shape and validation rules for plugin config. + +#### schema + +```ts +schema: JSONSchema7; +``` + +*** + +### description + +```ts +description: string; +``` + +Brief description of what the plugin does + +*** + +### displayName + +```ts +displayName: string; +``` + +Human-readable display name for UI/CLI + +*** + +### keywords? + +```ts +optional keywords: string[]; +``` + +*** + +### license? + +```ts +optional license: string; +``` + +*** + +### name + +```ts +name: string; +``` + +Plugin identifier (matches plugin.name) + +*** + +### repository? + +```ts +optional repository: string; +``` + +*** + +### resources + +```ts +resources: { + optional: Omit[]; + required: Omit[]; +}; +``` + +Resource requirements declaration + +#### optional + +```ts +optional: Omit[]; +``` + +Resources that enhance functionality but are not mandatory + +#### required + +```ts +required: Omit[]; +``` + +Resources that must be available for the plugin to function + +*** + +### version? + +```ts +optional version: string; +``` diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md new file mode 100644 index 00000000..c56a226a --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -0,0 +1,149 @@ +# Interface: ResourceEntry + +Internal representation of a resource in the registry. +Extends ResourceRequirement with resolution state and plugin ownership. + +## Extends + +- [`ResourceRequirement`](Interface.ResourceRequirement.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`alias`](Interface.ResourceRequirement.md#alias) + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`description`](Interface.ResourceRequirement.md#description) + +*** + +### fields + +```ts +fields: Record; +``` + +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).[`fields`](Interface.ResourceRequirement.md#fields) + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`permission`](Interface.ResourceRequirement.md#permission) + +*** + +### permissionSources? + +```ts +optional permissionSources: Record; +``` + +Per-plugin permission tracking. +Maps plugin name to the permission it originally requested. +Populated when multiple plugins share the same resource. + +*** + +### plugin + +```ts +plugin: string; +``` + +Plugin(s) that require this resource (comma-separated if multiple) + +*** + +### required + +```ts +required: boolean; +``` + +Whether this resource is required (true) or optional (false) + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`required`](Interface.ResourceRequirement.md#required) + +*** + +### resolved + +```ts +resolved: boolean; +``` + +Whether the resource has been resolved (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) + +*** + +### type + +```ts +type: ResourceType; +``` + +Type of Databricks resource required + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`type`](Interface.ResourceRequirement.md#type) + +*** + +### values? + +```ts +optional values: Record; +``` + +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 new file mode 100644 index 00000000..ed040a88 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceRequirement.md @@ -0,0 +1,79 @@ +# Interface: ResourceRequirement + +Declares a resource requirement for a plugin. +Can be defined statically in a manifest or dynamically via getResourceRequirements(). + +## Extended by + +- [`ResourceEntry`](Interface.ResourceEntry.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +*** + +### fields + +```ts +fields: Record; +``` + +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. + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +*** + +### required + +```ts +required: boolean; +``` + +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 +type: ResourceType; +``` + +Type of Databricks resource required diff --git a/docs/docs/api/appkit/Interface.ValidationResult.md b/docs/docs/api/appkit/Interface.ValidationResult.md new file mode 100644 index 00000000..f71a4bce --- /dev/null +++ b/docs/docs/api/appkit/Interface.ValidationResult.md @@ -0,0 +1,33 @@ +# Interface: ValidationResult + +Result of validating all registered resources against the environment. + +## Properties + +### all + +```ts +all: ResourceEntry[]; +``` + +Complete list of all registered resources (required and optional) + +*** + +### missing + +```ts +missing: ResourceEntry[]; +``` + +List of missing required resources + +*** + +### valid + +```ts +valid: boolean; +``` + +Whether all required resources are available diff --git a/docs/docs/api/appkit/TypeAlias.ConfigSchema.md b/docs/docs/api/appkit/TypeAlias.ConfigSchema.md new file mode 100644 index 00000000..6d07220e --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ConfigSchema.md @@ -0,0 +1,12 @@ +# Type Alias: ConfigSchema + +```ts +type ConfigSchema = JSONSchema7; +``` + +Configuration schema definition for plugin config. +Re-exported from the standard JSON Schema Draft 7 types. + +## See + +[JSON Schema Draft 7](https://json-schema.org/draft-07/json-schema-release-notes) diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md new file mode 100644 index 00000000..76bc8723 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -0,0 +1,19 @@ +# Type Alias: ResourcePermission + +```ts +type ResourcePermission = + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; +``` + +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 772f3db3..f1a0e5f8 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -3,6 +3,12 @@ Core library for building Databricks applications with type-safe SQL queries, plugin architecture, and React integration. +## Enumerations + +| Enumeration | Description | +| ------ | ------ | +| [ResourceType](Enumeration.ResourceType.md) | Supported resource types that plugins can depend on. Each type has its own set of valid permissions. | + ## Classes | Class | Description | @@ -13,7 +19,8 @@ plugin architecture, and React integration. | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | -| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins | +| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | +| [ResourceRegistry](Class.ResourceRegistry.md) | Central registry for tracking plugin resource requirements. Deduplication uses type + resourceKey (machine-stable); alias is for display only. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | @@ -25,14 +32,21 @@ plugin architecture, and React integration. | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for caching | | [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 | +| [ValidationResult](Interface.ValidationResult.md) | Result of validating all registered resources against the environment. | ## Type Aliases | Type Alias | Description | | ------ | ------ | +| [ConfigSchema](TypeAlias.ConfigSchema.md) | Configuration schema definition for plugin config. Re-exported from the standard JSON Schema Draft 7 types. | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | +| [ResourcePermission](TypeAlias.ResourcePermission.md) | Union of all possible permission levels across all resource types. | ## Variables @@ -47,4 +61,6 @@ plugin architecture, and React integration. | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | +| [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. Normalizes string type/permission to strict ResourceType/ResourcePermission. | +| [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 8fd695d5..aa114b63 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -1,6 +1,17 @@ import { SidebarsConfig } from "@docusaurus/plugin-content-docs"; const typedocSidebar: SidebarsConfig = { items: [ + { + type: "category", + label: "Enumerations", + items: [ + { + type: "doc", + id: "api/appkit/Enumeration.ResourceType", + label: "ResourceType" + } + ] + }, { type: "category", label: "Classes", @@ -40,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", @@ -76,6 +92,26 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ITelemetry", label: "ITelemetry" }, + { + type: "doc", + id: "api/appkit/Interface.PluginManifest", + label: "PluginManifest" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceEntry", + label: "ResourceEntry" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceFieldEntry", + label: "ResourceFieldEntry" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceRequirement", + label: "ResourceRequirement" + }, { type: "doc", id: "api/appkit/Interface.StreamExecutionSettings", @@ -85,6 +121,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/Interface.TelemetryConfig", label: "TelemetryConfig" + }, + { + type: "doc", + id: "api/appkit/Interface.ValidationResult", + label: "ValidationResult" } ] }, @@ -92,10 +133,20 @@ const typedocSidebar: SidebarsConfig = { type: "category", label: "Type Aliases", items: [ + { + type: "doc", + id: "api/appkit/TypeAlias.ConfigSchema", + label: "ConfigSchema" + }, { type: "doc", id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.ResourcePermission", + label: "ResourcePermission" } ] }, @@ -129,6 +180,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.getExecutionContext", label: "getExecutionContext" }, + { + type: "doc", + id: "api/appkit/Function.getPluginManifest", + label: "getPluginManifest" + }, + { + type: "doc", + id: "api/appkit/Function.getResourceRequirements", + label: "getResourceRequirements" + }, { type: "doc", id: "api/appkit/Function.isSQLTypeMarker", diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 1c8fee2a..4fa9fa5d 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,29 @@ 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", + resourceKey: "apiKey", + description: "API key for external service", + permission: "READ", + fields: { + scope: { env: "MY_SECRET_SCOPE", description: "Secret scope" }, + key: { env: "MY_API_KEY", description: "Secret key name" } + } + } + ], + optional: [] + } + }; async setup() { // Initialize your plugin @@ -247,10 +269,66 @@ 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", resourceKey: "sqlWarehouse", description: "Query execution", permission: "CAN_USE", fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } } } + ], + optional: [ + // Listed as optional in manifest for static analysis + { type: "database", alias: "cache", resourceKey: "cache", description: "Query result caching (if enabled)", permission: "CAN_CONNECT_AND_CREATE", fields: { instance_name: { env: "DATABRICKS_CACHE_INSTANCE" }, database_name: { env: "DATABRICKS_CACHE_DB" } } } + ] + } + }; + + // 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", + resourceKey: "cache", + description: "Query result caching", + 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; + } +} +``` + +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..7a465eb0 --- /dev/null +++ b/docs/scripts/copy-schemas.ts @@ -0,0 +1,44 @@ +/** + * 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/shared/src/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/sidebars.ts b/docs/sidebars.ts index f7d99e0f..1dd673a3 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -20,7 +20,7 @@ const SUPPORTED_KINDS = new Set([ "typealias", "function", "variable", - "enum", + "enumeration", ]); function flattenSidebarWithKind(sidebarConfig: any): any[] { diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 1301f74a..d3f3ad1d 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -26,7 +26,7 @@ --api-kind-typealias-color: #ec4899; --api-kind-function-color: #10b981; --api-kind-variable-color: #f59e0b; - --api-kind-enum-color: #6366f1; + --api-kind-enumeration-color: #6366f1; } [data-theme="light"] { @@ -62,7 +62,7 @@ .api-kind-typealias .menu__link::before, .api-kind-function .menu__link::before, .api-kind-variable .menu__link::before, -.api-kind-enum .menu__link::before, +.api-kind-enumeration .menu__link::before, .api-kind-other .menu__link::before { display: inline-block; width: 1.5em; @@ -109,9 +109,9 @@ content: "V"; } -.api-kind-enum .menu__link::before { - border-color: var(--api-kind-enum-color); - color: var(--api-kind-enum-color); +.api-kind-enumeration .menu__link::before { + border-color: var(--api-kind-enumeration-color); + color: var(--api-kind-enumeration-color); content: "E"; } diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index e497c06a..a9f095f5 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -221,6 +221,9 @@ .invisible { visibility: hidden; } + .visible { + visibility: visible; + } .sr-only { position: absolute; width: 1px; diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..8f8c9feb --- /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": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", + "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/package.json b/packages/appkit/package.json index b142fe36..c57e2bee 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -70,6 +70,7 @@ }, "devDependencies": { "@types/express": "^4.17.25", + "@types/json-schema": "^7.0.15", "@types/pg": "^8.15.6", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1" diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index ed226b36..86f74e0d 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -10,6 +10,7 @@ import type { } from "shared"; import { CacheManager } from "../cache"; import { ServiceContext } from "../context"; +import { ResourceRegistry } from "../registry"; import type { TelemetryConfig } from "../telemetry"; import { TelemetryManager } from "../telemetry"; @@ -71,8 +72,6 @@ export class AppKit { this.#pluginInstances[name] = pluginInstance; - pluginInstance.validateEnv(); - this.#setupPromises.push(pluginInstance.setup()); const self = this; @@ -154,6 +153,11 @@ export class AppKit { await ServiceContext.initialize(config?.client); const rawPlugins = config.plugins as T; + + const registry = new ResourceRegistry(); + 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 bc2df7a4..41aa73b0 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -2,11 +2,23 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import type { BasePlugin } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; +import type { PluginManifest } from "../../registry/types"; +import { ResourceType } from "../../registry/types"; import { AppKit, createApp } from "../appkit"; -// Mock environment validation +// Generic test manifest for test plugins +const createTestManifest = (name: string): PluginManifest => ({ + name, + displayName: `${name} Test Plugin`, + description: `Test plugin for ${name}`, + resources: { + required: [], + optional: [], + }, +}); + +// Mock utilities vi.mock("../utils", () => ({ - validateEnv: vi.fn(), deepMerge: vi.fn((a, b) => ({ ...a, ...b })), })); @@ -32,19 +44,15 @@ vi.mock("@databricks-apps/cache", () => ({ class CoreTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { coreDefault: "core-value" }; static phase = "core" as const; + 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; } @@ -59,7 +67,6 @@ class CoreTestPlugin implements BasePlugin { return { // Expose internal state for testing setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -68,19 +75,15 @@ class CoreTestPlugin implements BasePlugin { class NormalTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { normalDefault: "normal-value" }; static phase = "normal" as const; + 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; } @@ -94,7 +97,6 @@ class NormalTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, }; } @@ -103,9 +105,9 @@ class NormalTestPlugin implements BasePlugin { class DeferredTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { deferredDefault: "deferred-value" }; static phase = "deferred" as const; + static manifest = createTestManifest("deferredTest"); name = "deferredTest"; setupCalled = false; - validateEnvCalled = false; injectedConfig: any; injectedPlugins: any; @@ -114,10 +116,6 @@ class DeferredTestPlugin implements BasePlugin { this.injectedPlugins = config.plugins; } - validateEnv() { - this.validateEnvCalled = true; - } - async setup() { this.setupCalled = true; } @@ -131,7 +129,6 @@ class DeferredTestPlugin implements BasePlugin { exports() { return { setupCalled: this.setupCalled, - validateEnvCalled: this.validateEnvCalled, injectedConfig: this.injectedConfig, injectedPlugins: this.injectedPlugins, }; @@ -140,6 +137,7 @@ class DeferredTestPlugin implements BasePlugin { class SlowSetupPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("slowSetup"); name = "slowSetup"; setupDelay: number; setupCalled = false; @@ -148,8 +146,6 @@ class SlowSetupPlugin implements BasePlugin { this.setupDelay = config.setupDelay || 100; } - validateEnv() {} - async setup() { await new Promise((resolve) => setTimeout(resolve, this.setupDelay)); this.setupCalled = true; @@ -170,12 +166,9 @@ class SlowSetupPlugin implements BasePlugin { class FailingPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("failing"); name = "failing"; - validateEnv() { - throw new Error("Environment validation failed"); - } - async setup() { throw new Error("Setup failed"); } @@ -228,7 +221,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 () => { @@ -336,34 +328,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" }, @@ -523,14 +489,99 @@ describe("AppKit", () => { }); }); + describe("createApp resource validation (collectResources + enforceValidation)", () => { + test("should throw in production when required resource env is missing", async () => { + const PluginWithRequiredResource = class extends CoreTestPlugin { + static manifest: PluginManifest = { + name: "withResource", + displayName: "With Resource", + description: "Plugin with required warehouse", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + }; + + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const pluginData = [ + { + plugin: PluginWithRequiredResource, + config: {}, + name: "withResource", + }, + ]; + await expect(createApp({ plugins: pluginData })).rejects.toThrow(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + test("should succeed when required resource env is set", async () => { + const PluginWithRequiredResource = class extends CoreTestPlugin { + static manifest: PluginManifest = { + name: "withResource", + displayName: "With Resource", + description: "Plugin with required warehouse", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + }; + + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.DATABRICKS_WAREHOUSE_ID = "wh-123"; + try { + const pluginData = [ + { + plugin: PluginWithRequiredResource, + config: {}, + name: "withResource", + }, + ]; + const instance = await createApp({ plugins: pluginData }); + expect(instance).toBeDefined(); + expect((instance as any).withResource).toBeDefined(); + } finally { + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + }); + describe("SDK context binding", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("contextTest"); name = "contextTest"; private counter = 0; - validateEnv() {} async setup() {} injectRoutes() {} getEndpoints() { @@ -567,10 +618,10 @@ describe("AppKit", () => { test("should maintain context when SDK method is passed as callback", async () => { class CallbackTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("callbackTest"); 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 12165794..b0745592 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,6 +31,22 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, server } from "./plugins"; +// Registry types and utilities for plugin manifests +export type { + ConfigSchema, + PluginManifest, + ResourceEntry, + ResourceFieldEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./registry"; +export { + getPluginManifest, + getResourceRequirements, + ResourceRegistry, + ResourceType, +} from "./registry"; // Telemetry (for advanced custom telemetry) export { type Counter, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index c5050ca9..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", @@ -59,7 +58,91 @@ const EXCLUDED_FROM_PROXY = new Set([ "constructor", ]); -/** Base abstract class for creating AppKit plugins */ +/** + * Base abstract class for creating AppKit plugins. + * + * All plugins must declare a static `manifest` property with their metadata + * and resource requirements. The manifest defines: + * - `required` resources: Always needed for the plugin to function + * - `optional` resources: May be needed depending on plugin configuration + * + * ## 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'; + * + * const myManifest: PluginManifest = { + * name: 'myPlugin', + * displayName: 'My Plugin', + * description: 'Does something awesome', + * resources: { + * required: [ + * { type: ResourceType.SQL_WAREHOUSE, alias: 'warehouse', ... } + * ], + * optional: [] + * } + * }; + * + * class MyPlugin extends Plugin { + * static manifest = myManifest; + * name = 'myPlugin'; + * } + * ``` + * + * @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'; + * + * // 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 abstract class Plugin< TConfig extends BasePluginConfig = BasePluginConfig, > implements BasePlugin @@ -70,12 +153,21 @@ 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 = {}; + /** + * Plugin initialization phase. + * - 'core': Initialized first (e.g., config plugins) + * - 'normal': Initialized second (most plugins) + * - 'deferred': Initialized last (e.g., server plugin) + */ static phase: PluginPhase = "normal"; + + /** + * Plugin name identifier. + */ name: string; constructor(protected config: TConfig) { @@ -89,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 a631a776..1619bdf0 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -15,6 +15,7 @@ import { import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { queryDefaults } from "./defaults"; +import { analyticsManifest } from "./manifest"; import { QueryProcessor } from "./query"; import type { AnalyticsQueryResponse, @@ -26,7 +27,9 @@ const logger = createLogger("analytics"); export class AnalyticsPlugin extends Plugin { name = "analytics"; - protected envVars: string[] = []; + + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = analyticsManifest; protected static description = "Analytics plugin for data analysis"; protected declare config: IAnalyticsConfig; diff --git a/packages/appkit/src/plugins/analytics/index.ts b/packages/appkit/src/plugins/analytics/index.ts index 9ad02125..56774782 100644 --- a/packages/appkit/src/plugins/analytics/index.ts +++ b/packages/appkit/src/plugins/analytics/index.ts @@ -1,2 +1,3 @@ export * from "./analytics"; +export * from "./manifest"; export * from "./types"; diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json new file mode 100644 index 00000000..4a6a60c2 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -0,0 +1,36 @@ +{ + "$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" + } + } + } + } +} diff --git a/packages/appkit/src/plugins/analytics/manifest.ts b/packages/appkit/src/plugins/analytics/manifest.ts new file mode 100644 index 00000000..fe74e345 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.ts @@ -0,0 +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)); + +/** + * 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 = 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 62b3e7bd..40cf01e0 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -8,6 +8,7 @@ import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { instrumentations } from "../../telemetry"; +import { serverManifest } from "./manifest"; import { RemoteTunnelController } from "./remote-tunnel/remote-tunnel-controller"; import { StaticServer } from "./static-server"; import type { ServerConfig } from "./types"; @@ -39,8 +40,10 @@ export class ServerPlugin extends Plugin { port: Number(process.env.DATABRICKS_APP_PORT) || 8000, }; + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = serverManifest; + public name = "server" as const; - protected envVars: string[] = []; private serverApplication: express.Application; private server: HTTPServer | null; private viteDevServer?: ViteDevServer; @@ -355,3 +358,7 @@ export const server = toPlugin( ServerPlugin, "server", ); + +// Export manifest and types +export { serverManifest } from "./manifest"; +export type { ServerConfig } from "./types"; diff --git a/packages/appkit/src/plugins/server/manifest.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 new file mode 100644 index 00000000..97a4c716 --- /dev/null +++ b/packages/appkit/src/plugins/server/manifest.ts @@ -0,0 +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 = 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 c752f797..84496348 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -98,8 +98,13 @@ describe("ServerPlugin with custom plugin", () => { // Create a simple test plugin class TestPlugin extends Plugin { + static manifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "Test plugin for integration tests", + 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 new file mode 100644 index 00000000..bc543027 --- /dev/null +++ b/packages/appkit/src/registry/index.ts @@ -0,0 +1,36 @@ +/** + * Resource Registry System + * + * The registry system enables plugins to declare their Databricks resource + * requirements (SQL Warehouses, Lakebase instances, etc.) in a standardized way. + * + * Components: + * - Type definitions for resources, manifests, and validation + * - Manifest loader for reading plugin declarations + * - 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/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts new file mode 100644 index 00000000..5c58f1fd --- /dev/null +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -0,0 +1,200 @@ +import type { PluginConstructor } from "shared"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging/logger"; +import type { + PluginManifest, + ResourcePermission, + ResourceRequirement, +} from "./types"; +import { PERMISSIONS_BY_TYPE, ResourceType } from "./types"; + +const logger = createLogger("manifest-loader"); + +/** Loose resource from shared/manifest (string type and permission). */ +interface LooseResource { + type: string; + alias: string; + resourceKey: string; + description: string; + permission: string; + fields: Record; +} + +function normalizeType(s: string): ResourceType { + const v = Object.values(ResourceType).find((x) => x === s); + if (v !== undefined) return v; + throw new ConfigurationError( + `Invalid resource type: "${s}". Valid: ${Object.values(ResourceType).join(", ")}`, + ); +} + +function normalizePermission( + type: ResourceType, + s: string, +): ResourcePermission { + const allowed = PERMISSIONS_BY_TYPE[type]; + if (allowed.includes(s as ResourcePermission)) return s as ResourcePermission; + throw new ConfigurationError( + `Invalid permission "${s}" for type ${type}. Valid: ${allowed.join(", ")}`, + ); +} + +function normalizeResource(r: LooseResource): ResourceRequirement { + const type = normalizeType(r.type); + const permission = normalizePermission(type, r.permission); + return { + ...r, + type, + permission, + required: false, + }; +} + +/** + * Loads and validates the manifest from a plugin constructor. + * Normalizes string type/permission to strict ResourceType/ResourcePermission. + * + * @param plugin - The plugin constructor class + * @returns The validated, normalized plugin manifest + * @throws {ConfigurationError} If the manifest is missing, invalid, or has invalid resource type/permission + */ +export function getPluginManifest(plugin: PluginConstructor): PluginManifest { + const pluginName = plugin.name || "unknown"; + + if (!plugin.manifest) { + throw new ConfigurationError( + `Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`, + ); + } + + const raw = plugin.manifest; + + if (!raw.name || typeof raw.name !== "string") { + throw new ConfigurationError( + `Plugin ${pluginName} manifest has missing or invalid 'name' field`, + ); + } + + if (!raw.displayName || typeof raw.displayName !== "string") { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has missing or invalid 'displayName' field`, + ); + } + + if (!raw.description || typeof raw.description !== "string") { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has missing or invalid 'description' field`, + ); + } + + if (!raw.resources) { + throw new ConfigurationError( + `Plugin ${raw.name} manifest is missing 'resources' field`, + ); + } + + if (!Array.isArray(raw.resources.required)) { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has invalid 'resources.required' field (expected array)`, + ); + } + + if ( + raw.resources.optional !== undefined && + !Array.isArray(raw.resources.optional) + ) { + throw new ConfigurationError( + `Plugin ${raw.name} manifest has invalid 'resources.optional' field (expected array)`, + ); + } + + const required = raw.resources.required.map((r) => { + const norm = normalizeResource(r as LooseResource); + const { required: _, ...rest } = norm; + return rest; + }); + const optional = (raw.resources.optional || []).map((r) => { + const norm = normalizeResource(r as LooseResource); + const { required: _, ...rest } = norm; + return rest; + }); + + logger.debug( + "Loaded manifest for plugin %s: %d required resources, %d optional resources", + raw.name, + required.length, + optional.length, + ); + + return { + ...raw, + resources: { required, optional }, + }; +} + +/** + * Gets the resource requirements from a plugin's manifest. + * + * Combines required and optional resources into a single array with the + * `required` flag set appropriately. + * + * @param plugin - The plugin constructor class + * @returns Combined array of required and optional resources + * @throws {ConfigurationError} If the plugin manifest is missing or invalid + * + * @example + * ```typescript + * const resources = getResourceRequirements(AnalyticsPlugin); + * for (const resource of resources) { + * console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); + * } + * ``` + */ +export function getResourceRequirements(plugin: PluginConstructor) { + const manifest = getPluginManifest(plugin); + + const required = manifest.resources.required.map((r) => ({ + ...r, + required: true, + })); + const optional = (manifest.resources.optional || []).map((r) => ({ + ...r, + required: false, + })); + + return [...required, ...optional]; +} + +/** + * Validates a manifest object structure. + * + * @param manifest - The manifest object to validate + * @returns true if the manifest is valid, false otherwise + * + * @internal + */ +export function isValidManifest(manifest: unknown): manifest is PluginManifest { + if (!manifest || typeof manifest !== "object") { + return false; + } + + const m = manifest as Record; + + // Check required fields + if (typeof m.name !== "string") return false; + if (typeof m.displayName !== "string") return false; + if (typeof m.description !== "string") return false; + + // Check resources structure + if (!m.resources || typeof m.resources !== "object") return false; + + const resources = m.resources as Record; + if (!Array.isArray(resources.required)) return false; + + // Optional field can be missing or must be an array + if (resources.optional !== undefined && !Array.isArray(resources.optional)) { + return false; + } + + return true; +} diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts new file mode 100644 index 00000000..2fca1a02 --- /dev/null +++ b/packages/appkit/src/registry/resource-registry.ts @@ -0,0 +1,465 @@ +/** + * Resource Registry + * + * Central registry that tracks all resource requirements across all plugins. + * Provides visibility into Databricks resources needed by the application + * and handles deduplication when multiple plugins require the same resource + * (dedup key: type + resourceKey). + * + * Use `new ResourceRegistry()` for instance-scoped usage (e.g. createApp). + * getInstance() / resetInstance() remain for backward compatibility in tests. + */ + +import type { BasePluginConfig, PluginConstructor, PluginData } from "shared"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging/logger"; +import { getPluginManifest } from "./manifest-loader"; +import type { + ResourceEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./types"; +import { PERMISSION_HIERARCHY_BY_TYPE, type ResourceType } from "./types"; + +const logger = createLogger("resource-registry"); + +/** + * Dedup key for registry: type + resourceKey (machine-stable). + * alias is for UI/display only. + */ +function getDedupKey(type: string, resourceKey: string): string { + return `${type}:${resourceKey}`; +} + +/** + * Returns the most permissive permission for a given resource type. + * Uses per-type hierarchy; unknown permissions are treated as least permissive. + */ +function getMostPermissivePermission( + resourceType: ResourceType, + p1: ResourcePermission, + p2: ResourcePermission, +): ResourcePermission { + const hierarchy = PERMISSION_HIERARCHY_BY_TYPE[resourceType as ResourceType]; + const index1 = hierarchy?.indexOf(p1) ?? -1; + const index2 = hierarchy?.indexOf(p2) ?? -1; + return index1 > index2 ? p1 : p2; +} + +/** + * Central registry for tracking plugin resource requirements. + * Deduplication uses type + resourceKey (machine-stable); alias is for display only. + */ +export class ResourceRegistry { + private resources: Map = new Map(); + + /** + * Registers a resource requirement for a plugin. + * If a resource with the same type+resourceKey already exists, merges them: + * - Combines plugin names (comma-separated) + * - Uses the most permissive permission (per-type hierarchy) + * - Marks as required if any plugin requires it + * - Combines descriptions if they differ + * - Merges fields; warns when same field name uses different env vars + * + * @param plugin - Name of the plugin registering the resource + * @param resource - Resource requirement specification + */ + public register(plugin: string, resource: ResourceRequirement): void { + const key = getDedupKey(resource.type, resource.resourceKey); + 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 with permission source tracking + const entry: ResourceEntry = { + ...resource, + plugin, + resolved: false, + permissionSources: { [plugin]: resource.permission }, + }; + this.resources.set(key, entry); + } + } + + /** + * Collects and registers resource requirements from an array of plugins. + * For each plugin, loads its manifest (required) and runtime resource requirements. + * + * @param rawPlugins - Array of plugin data entries from createApp configuration + * @throws {ConfigurationError} If any plugin is missing a manifest or manifest is invalid + */ + public collectResources( + rawPlugins: PluginData[], + ): void { + for (const pluginData of rawPlugins) { + if (!pluginData?.plugin) continue; + + const pluginName = pluginData.name; + 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) { + this.register(pluginName, resource as ResourceRequirement); + } + } + + logger.debug( + "Collected resources from plugin %s: %d total", + pluginName, + this.getByPlugin(pluginName).length, + ); + } + } + + /** + * 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); + } + + // Track per-plugin permission sources + const permissionSources: Record = { + ...(existing.permissionSources ?? {}), + [newPlugin]: newResource.permission, + }; + + // Use the most permissive permission for this resource type; warn when escalating + const permission = getMostPermissivePermission( + existing.type as ResourceType, + existing.permission, + newResource.permission, + ); + + if (permission !== existing.permission) { + logger.warn( + 'Resource %s:%s permission escalated from "%s" to "%s" due to plugin "%s" ' + + "(previously requested by: %s). Review plugin permissions to ensure least-privilege.", + existing.type, + existing.resourceKey, + existing.permission, + permission, + newPlugin, + existing.plugin, + ); + } + + // 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 + ) { + if (!existing.description.includes(newResource.description)) { + description = `${existing.description}; ${newResource.description}`; + } + } + + // Merge fields: union of field names; warn when same field name uses different env + const fields = { ...(existing.fields ?? {}) }; + for (const [fieldName, newField] of Object.entries( + newResource.fields ?? {}, + )) { + const existingField = fields[fieldName]; + if (existingField) { + if (existingField.env !== newField.env) { + logger.warn( + 'Resource %s:%s field "%s": conflicting env vars "%s" (from %s) vs "%s" (from %s). Using first.', + existing.type, + existing.resourceKey, + fieldName, + existingField.env, + existing.plugin, + newField.env, + newPlugin, + ); + } + // keep existing + } else { + fields[fieldName] = newField; + } + } + + return { + ...existing, + plugin: plugins.join(", "), + permission, + permissionSources, + 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 resourceKey (dedup key). + * + * @param type - Resource type + * @param resourceKey - Stable machine key (not alias; alias is for display only) + * @returns The resource entry if found, undefined otherwise + */ + public get(type: string, resourceKey: string): ResourceEntry | undefined { + return this.resources.get(getDedupKey(type, resourceKey)); + } + + /** + * 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, unless + * `APPKIT_STRICT_VALIDATION=true` is set, in which case throws like production. + * - When all resources are valid: logs a debug message with the count. + * + * @returns ValidationResult with validity status, missing resources, and all resources + * @throws {ConfigurationError} In production when required resources are missing, or in dev when APPKIT_STRICT_VALIDATION=true + */ + public enforceValidation(): ValidationResult { + const validation = this.validate(); + const isDevelopment = process.env.NODE_ENV === "development"; + const strictValidation = + process.env.APPKIT_STRICT_VALIDATION === "true" || + process.env.APPKIT_STRICT_VALIDATION === "1"; + + if (!validation.valid) { + const errorMessage = ResourceRegistry.formatMissingResources( + validation.missing, + ); + + const shouldThrow = !isDevelopment || strictValidation; + + if (shouldThrow) { + 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), + })), + }, + }); + } + + // Dev mode without strict: use a visually prominent box so the warning can't be missed + const banner = ResourceRegistry.formatDevWarningBanner( + validation.missing, + ); + logger.warn("\n%s", banner); + } else if (this.size() > 0) { + logger.debug("All %d resources validated successfully", this.size()); + } + + 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")}`; + } + + /** + * Formats a highly visible warning banner for dev-mode missing resources. + * Uses box drawing to ensure the message is impossible to miss in scrolling logs. + * + * @param missing - Array of missing resource entries + * @returns Formatted banner string + */ + public static formatDevWarningBanner(missing: ResourceEntry[]): string { + const contentLines: string[] = [ + "MISSING REQUIRED RESOURCES (dev mode — would fail in production)", + "", + ]; + + for (const entry of missing) { + const envVars = Object.values(entry.fields).map((f) => f.env); + contentLines.push( + ` ${entry.type}:${entry.alias} (plugin: ${entry.plugin})`, + ); + contentLines.push(` Set: ${envVars.join(", ")}`); + } + + contentLines.push(""); + contentLines.push( + "Add these to your .env file or environment to suppress this warning.", + ); + + const maxLen = Math.max(...contentLines.map((l) => l.length)); + const border = "=".repeat(maxLen + 4); + + const boxed = contentLines.map((line) => `| ${line.padEnd(maxLen)} |`); + + return [border, ...boxed, border].join("\n"); + } +} diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts new file mode 100644 index 00000000..cbc66005 --- /dev/null +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { AnalyticsPlugin } from "../../plugins/analytics/analytics"; +import { ServerPlugin } from "../../plugins/server"; +import { getPluginManifest, getResourceRequirements } from "../manifest-loader"; +import { ResourceType } from "../types"; + +describe("Manifest Loader Integration", () => { + describe("ServerPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(ServerPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("server"); + expect(manifest?.displayName).toBe("Server Plugin"); + }); + + it("should have no required resources", () => { + const resources = getResourceRequirements(ServerPlugin); + expect(resources).toHaveLength(0); + }); + }); + + describe("AnalyticsPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("analytics"); + expect(manifest?.displayName).toBe("Analytics Plugin"); + }); + + it("should require SQL Warehouse (no optional resources in manifest)", () => { + const resources = getResourceRequirements(AnalyticsPlugin); + expect(resources).toHaveLength(1); + + const required = resources.find((r) => r.required); + expect(required).toBeDefined(); + + expect(required).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + resourceKey: "sql-warehouse", + required: true, + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + }); + + it("should have correct resource description", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest?.resources.required[0].description).toBe( + "SQL Warehouse for executing analytics queries", + ); + }); + }); +}); diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts new file mode 100644 index 00000000..4e18170e --- /dev/null +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -0,0 +1,557 @@ +import type { PluginConstructor } from "shared"; +import { describe, expect, it } from "vitest"; +import { ConfigurationError } from "../../errors"; +import { + getPluginManifest, + getResourceRequirements, + isValidManifest, +} from "../manifest-loader"; +import type { PluginManifest } from "../types"; +import { ResourceType } from "../types"; + +describe("Manifest Loader", () => { + describe("getPluginManifest", () => { + it("should return manifest for plugin with valid manifest", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "sql-warehouse", + description: "Test warehouse", + permission: "CAN_USE", + fields: { + id: { env: "TEST_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(/missing a manifest/i); + }); + + it("should throw error for plugin with invalid manifest (missing name)", () => { + const invalidManifest = { + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'name' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing displayName)", () => { + const invalidManifest = { + name: "test-plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'displayName' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing description)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'description' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing resources)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/missing 'resources' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.required not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: "not-an-array", + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.required' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.optional not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: "not-an-array", + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.optional' field/i); + }); + + it("should handle plugin with optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET, + alias: "Secret", + resourceKey: "secret", + description: "Optional secrets", + permission: "READ", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + }); + + describe("getResourceRequirements", () => { + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getResourceRequirements( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + }); + + it("should return required resources with required=true", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + required: true, + }); + }); + + it("should return optional resources with required=false", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET, + alias: "secrets", + resourceKey: "secrets", + description: "Optional secrets", + permission: "READ", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SECRET, + alias: "secrets", + required: false, + }); + }); + + it("should return both required and optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, + }, + ], + optional: [ + { + type: ResourceType.SECRET, + alias: "secrets", + resourceKey: "secrets", + description: "Optional secrets", + permission: "READ", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(2); + expect(resources[0].required).toBe(true); + expect(resources[1].required).toBe(false); + }); + + it("should return 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", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(0); + }); + }); + + describe("isValidManifest", () => { + it("should return true for valid manifest", () => { + const validManifest: PluginManifest = { + name: "test", + displayName: "Test", + description: "Test plugin", + resources: { + required: [], + optional: [], + }, + }; + + expect(isValidManifest(validManifest)).toBe(true); + }); + + it("should return false for null", () => { + expect(isValidManifest(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isValidManifest(undefined)).toBe(false); + }); + + it("should return false for non-object", () => { + expect(isValidManifest("string")).toBe(false); + expect(isValidManifest(123)).toBe(false); + expect(isValidManifest(true)).toBe(false); + }); + + it("should return false for manifest missing name", () => { + const invalid = { + displayName: "Test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing displayName", () => { + const invalid = { + name: "test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing description", () => { + const invalid = { + name: "test", + displayName: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing resources", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array required", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: "not-array", + optional: [], + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array optional", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: "not-array", + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return true for manifest without optional field", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + }, + }; + + expect(isValidManifest(valid)).toBe(true); + }); + + it("should return true for manifest with additional fields", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: [], + }, + author: "Test Author", + version: "1.0.0", + keywords: ["test"], + }; + + expect(isValidManifest(valid)).toBe(true); + }); + }); +}); 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..7d5598d5 --- /dev/null +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -0,0 +1,774 @@ +import type { PluginConstructor, PluginData } from "shared"; +import { describe, expect, it, vi } from "vitest"; +import { ResourceRegistry } from "../resource-registry"; +import type { ResourceRequirement } from "../types"; +import { ResourceType } from "../types"; + +describe("ResourceRegistry", () => { + describe("register and merge with fields", () => { + it("should register a multi-field resource (database)", () => { + const registry = new ResourceRegistry(); + 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 = new ResourceRegistry(); + 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 = new ResourceRegistry(); + 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 prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; + process.env[CACHE_INSTANCE] = "my-instance"; + process.env[CACHE_DB] = "my_db"; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + const result = registry.validate(); + expect(result.valid).toBe(true); + expect(result.missing).toHaveLength(0); + 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 (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; + else delete process.env[CACHE_INSTANCE]; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; + else delete process.env[CACHE_DB]; + } + }); + + it("should mark multi-field resource missing when any env var is unset", () => { + const prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; + delete process.env[CACHE_INSTANCE]; + delete process.env[CACHE_DB]; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + expect(result.missing[0].type).toBe("database"); + expect(result.missing[0].alias).toBe("cache"); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toBeUndefined(); + } finally { + if (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; + else delete process.env[CACHE_INSTANCE]; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; + else delete process.env[CACHE_DB]; + } + }); + + it("should mark multi-field resource missing when only one env var is set", () => { + const prev1 = process.env[CACHE_INSTANCE]; + const prev2 = process.env[CACHE_DB]; + process.env[CACHE_INSTANCE] = "my-instance"; + delete process.env[CACHE_DB]; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toEqual({ instance_name: "my-instance" }); + } finally { + if (prev1 !== undefined) process.env[CACHE_INSTANCE] = prev1; + else delete process.env[CACHE_INSTANCE]; + if (prev2 !== undefined) process.env[CACHE_DB] = prev2; + else delete process.env[CACHE_DB]; + } + }); + }); + + describe("permission escalation tracking", () => { + it("should track permissionSources for a single plugin", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permissionSources).toEqual({ "plugin-a": "CAN_USE" }); + }); + + it("should track permissionSources when merging multiple plugins", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_MANAGE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_MANAGE"); + expect(entry?.permissionSources).toEqual({ + "plugin-a": "CAN_USE", + "plugin-b": "CAN_MANAGE", + }); + }); + + it("should warn when permission is escalated during merge", () => { + const registry = new ResourceRegistry(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_MANAGE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + // The logger uses debug/console under the hood — verify final permission + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_MANAGE"); + + warnSpy.mockRestore(); + }); + + it("should not escalate when permissions are identical", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: false, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_USE"); + expect(entry?.permissionSources).toEqual({ + "plugin-a": "CAN_USE", + "plugin-b": "CAN_USE", + }); + }); + }); + + describe("enforceValidation", () => { + it("should throw in production when required resources are missing", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + expect(() => registry.enforceValidation()).toThrow(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + it("should throw in dev when APPKIT_STRICT_VALIDATION=true and resources missing", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevStrict = process.env.APPKIT_STRICT_VALIDATION; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "development"; + process.env.APPKIT_STRICT_VALIDATION = "true"; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + expect(() => registry.enforceValidation()).toThrow(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + process.env.APPKIT_STRICT_VALIDATION = prevStrict ?? ""; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + it("should only warn in dev when APPKIT_STRICT_VALIDATION is not set", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevStrict = process.env.APPKIT_STRICT_VALIDATION; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "development"; + delete process.env.APPKIT_STRICT_VALIDATION; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + const result = registry.enforceValidation(); + expect(result.valid).toBe(false); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevStrict !== undefined) + process.env.APPKIT_STRICT_VALIDATION = prevStrict; + else delete process.env.APPKIT_STRICT_VALIDATION; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + + it("should not throw in production when all required resources are set", () => { + const prevNodeEnv = process.env.NODE_ENV; + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + process.env.NODE_ENV = "production"; + process.env.DATABRICKS_WAREHOUSE_ID = "wh-123"; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + const result = registry.enforceValidation(); + expect(result.valid).toBe(true); + } finally { + process.env.NODE_ENV = prevNodeEnv; + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + }); + + describe("enforceValidation dev warning banner", () => { + it("should format a visible banner for dev mode", () => { + const banner = ResourceRegistry.formatDevWarningBanner([ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + required: true, + plugin: "analytics", + resolved: false, + }, + ]); + + expect(banner).toContain("MISSING REQUIRED RESOURCES"); + expect(banner).toContain("would fail in production"); + expect(banner).toContain("sql_warehouse:warehouse"); + expect(banner).toContain("DATABRICKS_WAREHOUSE_ID"); + expect(banner).toContain("analytics"); + expect(banner).toContain(".env"); + // Should have box borders + expect(banner).toContain("===="); + expect(banner).toContain("|"); + }); + }); + + describe("formatMissingResources with fields", () => { + it("should list field env vars for multi-field missing resources", () => { + const prevScope = process.env.SECRET_SCOPE; + const prevKey = process.env.SECRET_KEY; + delete process.env.SECRET_SCOPE; + delete process.env.SECRET_KEY; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE" }, + key: { env: "SECRET_KEY" }, + }, + }); + + const result = registry.validate(); + expect(result.valid).toBe(false); + + const formatted = ResourceRegistry.formatMissingResources( + result.missing, + ); + expect(formatted).toContain("secret:creds"); + expect(formatted).toContain("SECRET_SCOPE"); + expect(formatted).toContain("SECRET_KEY"); + } finally { + if (prevScope !== undefined) process.env.SECRET_SCOPE = prevScope; + else delete process.env.SECRET_SCOPE; + if (prevKey !== undefined) process.env.SECRET_KEY = prevKey; + else delete process.env.SECRET_KEY; + } + }); + + it("should list field env vars for single-value missing resources", () => { + const prevWh = process.env.DATABRICKS_WAREHOUSE_ID; + delete process.env.DATABRICKS_WAREHOUSE_ID; + try { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + const result = registry.validate(); + const formatted = ResourceRegistry.formatMissingResources( + result.missing, + ); + expect(formatted).toContain("DATABRICKS_WAREHOUSE_ID"); + } finally { + if (prevWh !== undefined) process.env.DATABRICKS_WAREHOUSE_ID = prevWh; + else delete process.env.DATABRICKS_WAREHOUSE_ID; + } + }); + }); + + describe("collectResources with getResourceRequirements", () => { + it("should register runtime resources from getResourceRequirements(config)", () => { + interface Config { + enableCache?: boolean; + } + const PluginWithRuntimeRequirements = class { + static manifest = { + name: "with-runtime", + displayName: "With Runtime", + description: "Plugin with runtime resources", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE" as const, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }; + static getResourceRequirements(config: Config): ResourceRequirement[] { + const base: ResourceRequirement[] = [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + required: true, + }, + ]; + if (config.enableCache) { + base.push({ + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache DB", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + instance_name: { env: "CACHE_INSTANCE" }, + database_name: { env: "CACHE_DB" }, + }, + required: true, + }); + } + return base; + } + }; + + const registry = new ResourceRegistry(); + const rawPlugins: PluginData[] = [ + { + name: "withRuntime", + plugin: PluginWithRuntimeRequirements as unknown as PluginConstructor, + config: { enableCache: true }, + }, + ]; + registry.collectResources(rawPlugins); + + expect(registry.size()).toBe(2); + expect(registry.get("sql_warehouse", "warehouse")).toBeDefined(); + expect(registry.get("database", "cache")).toBeDefined(); + expect(registry.getByPlugin("withRuntime")).toHaveLength(2); + }); + }); + + describe("mergeResources edge cases", () => { + it("should merge when second plugin adds new field names (union of fields)", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Creds", + permission: "READ", + required: true, + fields: { + scope: { env: "SCOPE_A", description: "Scope" }, + key: { env: "KEY_A", description: "Key" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Creds", + permission: "READ", + required: false, + fields: { + scope: { env: "SCOPE_B" }, + key: { env: "KEY_B" }, + extra_field: { env: "EXTRA_B", description: "Extra" }, + }, + }); + + const entry = registry.get("secret", "creds"); + expect(entry?.fields.scope.env).toBe("SCOPE_A"); + expect(entry?.fields.key.env).toBe("KEY_A"); + expect(entry?.fields.extra_field?.env).toBe("EXTRA_B"); + }); + + it("should treat unlisted permission as least permissive when merging", () => { + const registry = new ResourceRegistry(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "Warehouse", + permission: "UNKNOWN_PERMISSION" as any, + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.permission).toBe("CAN_USE"); + }); + }); + + describe("registry accessors", () => { + it("getByPlugin returns only resources for that plugin", () => { + const registry = new ResourceRegistry(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }); + registry.register("server", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: true, + fields: { id: { env: "DATABRICKS_APP_ID" } }, + }); + + const byAnalytics = registry.getByPlugin("analytics"); + const byServer = registry.getByPlugin("server"); + expect(byAnalytics).toHaveLength(1); + expect(byServer).toHaveLength(1); + expect(byAnalytics[0].type).toBe("sql_warehouse"); + expect(byServer[0].type).toBe("app"); + }); + + it("getRequired and getOptional filter by required flag", () => { + const registry = new ResourceRegistry(); + registry.register("p", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + registry.register("p", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: false, + fields: { id: { env: "APP_ID" } }, + }); + + expect(registry.getRequired()).toHaveLength(1); + expect(registry.getOptional()).toHaveLength(1); + expect(registry.getRequired()[0].resourceKey).toBe("warehouse"); + expect(registry.getOptional()[0].resourceKey).toBe("app"); + }); + + it("size returns count of unique resources (by type+resourceKey)", () => { + const registry = new ResourceRegistry(); + expect(registry.size()).toBe(0); + registry.register("a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.register("b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: false, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.register("b", { + type: ResourceType.APP, + alias: "app", + resourceKey: "app", + description: "App", + permission: "CAN_USE", + required: true, + fields: { id: { env: "APP_ID" } }, + }); + expect(registry.size()).toBe(2); + }); + + it("clear removes all resources", () => { + const registry = new ResourceRegistry(); + registry.register("a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "wh", + resourceKey: "warehouse", + description: "WH", + permission: "CAN_USE", + required: true, + fields: { id: { env: "WH_ID" } }, + }); + expect(registry.size()).toBe(1); + registry.clear(); + expect(registry.size()).toBe(0); + expect(registry.get("sql_warehouse", "warehouse")).toBeUndefined(); + }); + }); +}); diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts new file mode 100644 index 00000000..bcbc8d34 --- /dev/null +++ b/packages/appkit/src/registry/types.ts @@ -0,0 +1,273 @@ +/** + * Resource Registry Type System + * + * This module defines the type system for the AppKit Resource Registry, + * which enables plugins to declare their Databricks resource requirements + * in a machine-readable format. + * + * 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 resource types that plugins can depend on. + * Each type has its own set of valid permissions. + */ +export enum ResourceType { + /** Secret scope for secure credential storage */ + SECRET = "secret", + + /** Databricks Job for scheduled or triggered workflows */ + JOB = "job", + + /** Databricks SQL Warehouse for query execution */ + SQL_WAREHOUSE = "sql_warehouse", + + /** Model serving endpoint for ML inference */ + 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", + + /** Unity Catalog Connection for external data sources */ + UC_CONNECTION = "uc_connection", + + /** 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"; + +/** + * Union of all possible permission levels across all resource types. + */ +export type ResourcePermission = + | SecretPermission + | JobPermission + | SqlWarehousePermission + | ServingEndpointPermission + | VolumePermission + | VectorSearchIndexPermission + | UcFunctionPermission + | UcConnectionPermission + | DatabasePermission + | GenieSpacePermission + | ExperimentPermission + | AppPermission; + +/** + * Permission hierarchy per resource type (weakest to strongest). + * Used to compare permissions when merging; higher index = more permissive. + * Unknown permissions are treated as less than any known permission. + */ +export const PERMISSION_HIERARCHY_BY_TYPE: Record< + ResourceType, + readonly ResourcePermission[] +> = { + [ResourceType.SECRET]: ["READ", "WRITE", "MANAGE"], + [ResourceType.JOB]: ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], + [ResourceType.SQL_WAREHOUSE]: ["CAN_USE", "CAN_MANAGE"], + [ResourceType.SERVING_ENDPOINT]: ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], + [ResourceType.VOLUME]: ["READ_VOLUME", "WRITE_VOLUME"], + [ResourceType.VECTOR_SEARCH_INDEX]: ["SELECT"], + [ResourceType.UC_FUNCTION]: ["EXECUTE"], + [ResourceType.UC_CONNECTION]: ["USE_CONNECTION"], + [ResourceType.DATABASE]: ["CAN_CONNECT_AND_CREATE"], + [ResourceType.GENIE_SPACE]: ["CAN_VIEW", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"], + [ResourceType.EXPERIMENT]: ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + [ResourceType.APP]: ["CAN_USE"], +} as const; + +/** Set of valid permissions per type (for validation). */ +export const PERMISSIONS_BY_TYPE: Record< + ResourceType, + readonly ResourcePermission[] +> = PERMISSION_HIERARCHY_BY_TYPE; + +/** + * Defines a single field for a resource. Each field has its own environment variable and optional description. + * Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). + */ +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. + * Can be defined statically in a manifest or dynamically via getResourceRequirements(). + */ +export interface ResourceRequirement { + /** Type of Databricks resource required */ + type: ResourceType; + + /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). 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; + + /** Required permission level for the resource */ + permission: ResourcePermission; + + /** + * Map of field name to env and optional description. + * Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. + */ + fields: Record; + + /** Whether this resource is required (true) or optional (false) */ + required: boolean; +} + +/** + * Internal representation of a resource in the registry. + * Extends ResourceRequirement with resolution state and plugin ownership. + */ +export interface ResourceEntry extends ResourceRequirement { + /** Plugin(s) that require this resource (comma-separated if multiple) */ + plugin: string; + + /** Whether the resource has been resolved (all field env vars set) */ + resolved: boolean; + + /** Resolved value per field name. Populated by validate() when all field env vars are set. */ + values?: Record; + + /** + * Per-plugin permission tracking. + * Maps plugin name to the permission it originally requested. + * Populated when multiple plugins share the same resource. + */ + permissionSources?: Record; +} + +/** + * Result of validating all registered resources against the environment. + */ +export interface ValidationResult { + /** Whether all required resources are available */ + valid: boolean; + + /** List of missing required resources */ + missing: ResourceEntry[]; + + /** Complete list of all registered resources (required and optional) */ + all: ResourceEntry[]; +} + +import type { JSONSchema7 } from "json-schema"; + +/** + * Configuration schema definition for plugin config. + * Re-exported from the standard JSON Schema Draft 7 types. + * + * @see {@link https://json-schema.org/draft-07/json-schema-release-notes | JSON Schema Draft 7} + */ +export type ConfigSchema = JSONSchema7; + +/** + * Plugin manifest that declares metadata and resource requirements. + * Attached to plugin classes as a static property. + */ +export interface PluginManifest { + /** Plugin identifier (matches plugin.name) */ + name: string; + + /** Human-readable display name for UI/CLI */ + displayName: string; + + /** Brief description of what the plugin does */ + description: string; + + /** + * Resource requirements declaration + */ + resources: { + /** Resources that must be available for the plugin to function */ + required: Omit[]; + + /** Resources that enhance functionality but are not mandatory */ + optional: Omit[]; + }; + + /** + * Configuration schema for the plugin. + * Defines the shape and validation rules for plugin config. + */ + config?: { + schema: ConfigSchema; + }; + + /** + * Optional metadata for community plugins + */ + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} diff --git a/packages/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..2472c084 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -37,6 +37,15 @@ 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", + }, ], }, ]); 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/package.json b/packages/shared/package.json index d7558056..df890905 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@types/dependency-tree": "^8.1.4", "@types/express": "^4.17.21", + "@types/json-schema": "^7.0.15", "@types/ws": "^8.18.1", "dependency-tree": "^11.2.0" }, @@ -39,6 +40,8 @@ }, "dependencies": { "@ast-grep/napi": "^0.37.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "commander": "^12.1.0" } } diff --git a/packages/shared/src/cli/commands/plugins-sync.test.ts b/packages/shared/src/cli/commands/plugins-sync.test.ts new file mode 100644 index 00000000..592ff163 --- /dev/null +++ b/packages/shared/src/cli/commands/plugins-sync.test.ts @@ -0,0 +1,170 @@ +import path from "node:path"; +import { Lang, parse } from "@ast-grep/napi"; +import { describe, expect, it } from "vitest"; +import { + isWithinDirectory, + parseImports, + parsePluginUsages, +} from "./plugins-sync"; + +describe("plugins-sync", () => { + describe("isWithinDirectory", () => { + it("returns true when filePath equals boundary", () => { + const dir = path.resolve("/project/root"); + expect(isWithinDirectory(dir, dir)).toBe(true); + }); + + it("returns true when filePath is inside boundary", () => { + expect( + isWithinDirectory("/project/root/sub/file.ts", "/project/root"), + ).toBe(true); + expect(isWithinDirectory("/project/root/foo", "/project/root")).toBe( + true, + ); + }); + + it("returns false when filePath escapes boundary", () => { + expect( + isWithinDirectory("/project/root/../etc/passwd", "/project/root"), + ).toBe(false); + expect(isWithinDirectory("/other/file.ts", "/project/root")).toBe(false); + }); + + it("returns false when path is sibling (prefix edge case)", () => { + const root = path.resolve("/project/root"); + const sibling = path.resolve("/project/root-bar/file.ts"); + expect(isWithinDirectory(sibling, root)).toBe(false); + }); + + it("handles relative paths by resolving them", () => { + const cwd = process.cwd(); + expect(isWithinDirectory("package.json", cwd)).toBe(true); + }); + }); + + describe("parseImports", () => { + function parseCode(code: string) { + const ast = parse(Lang.TypeScript, code); + return parseImports(ast.root()); + } + + it("extracts named imports from a single statement", () => { + const imports = parseCode( + `import { createApp, server, analytics } from "@databricks/appkit";`, + ); + expect(imports).toHaveLength(3); + expect(imports.map((i) => i.name)).toEqual([ + "createApp", + "server", + "analytics", + ]); + expect(imports.map((i) => i.originalName)).toEqual([ + "createApp", + "server", + "analytics", + ]); + expect(imports[0].source).toBe("@databricks/appkit"); + }); + + it("extracts aliased imports", () => { + const imports = parseCode( + `import { createApp as initApp, server as srv } from "@databricks/appkit";`, + ); + expect(imports).toHaveLength(2); + expect(imports[0]).toEqual({ + name: "initApp", + originalName: "createApp", + source: "@databricks/appkit", + }); + expect(imports[1]).toEqual({ + name: "srv", + originalName: "server", + source: "@databricks/appkit", + }); + }); + + it("extracts relative imports", () => { + const imports = parseCode( + `import { myPlugin } from "./plugins/my-plugin";`, + ); + expect(imports).toHaveLength(1); + expect(imports[0].name).toBe("myPlugin"); + expect(imports[0].source).toBe("./plugins/my-plugin"); + }); + + it("handles double-quoted specifiers", () => { + const imports = parseCode(`import { foo } from "@databricks/appkit";`); + expect(imports[0].source).toBe("@databricks/appkit"); + }); + + it("returns empty array when no named imports", () => { + const imports = parseCode(`const x = 1;`); + expect(imports).toHaveLength(0); + }); + + it("handles multiple import statements", () => { + const imports = parseCode(` + import { createApp } from "@databricks/appkit"; + import { myPlugin } from "./my-plugin"; + `); + expect(imports).toHaveLength(2); + expect(imports[0].source).toBe("@databricks/appkit"); + expect(imports[1].source).toBe("./my-plugin"); + }); + }); + + describe("parsePluginUsages", () => { + function parseCode(code: string) { + const ast = parse(Lang.TypeScript, code); + return parsePluginUsages(ast.root()); + } + + it("extracts plugin names used in createApp plugins array", () => { + const used = parseCode(` + createApp({ + plugins: [ + server(), + analytics(), + ], + }); + `); + expect(Array.from(used)).toEqual( + expect.arrayContaining(["server", "analytics"]), + ); + expect(used.size).toBe(2); + }); + + it("ignores non-plugin call expressions in the same object", () => { + const used = parseCode(` + createApp({ + plugins: [server()], + telemetry: { enabled: true }, + }); + `); + expect(Array.from(used)).toEqual(["server"]); + }); + + it("returns empty set when no plugins key with array", () => { + const used = parseCode(`createApp({});`); + expect(used.size).toBe(0); + }); + + it("returns empty set when plugins is not an array of calls", () => { + const used = parseCode(` + createApp({ + plugins: [], + }); + `); + expect(used.size).toBe(0); + }); + + it("extracts single plugin usage", () => { + const used = parseCode(` + createApp({ + plugins: [server()], + }); + `); + expect(Array.from(used)).toEqual(["server"]); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugins-sync.ts new file mode 100644 index 00000000..ef4cbc2e --- /dev/null +++ b/packages/shared/src/cli/commands/plugins-sync.ts @@ -0,0 +1,642 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Lang, parse, type SgNode } from "@ast-grep/napi"; +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import { Command } from "commander"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Resolve to package schemas: from dist/cli/commands -> dist/schemas, from src/cli/commands -> shared/schemas +const PLUGIN_MANIFEST_SCHEMA_PATH = path.join( + __dirname, + "..", + "..", + "..", + "schemas", + "plugin-manifest.schema.json", +); + +/** + * Field entry in a resource requirement (env var + optional description) + */ +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; +} + +/** + * Checks whether a resolved file path is within a given directory boundary. + * Uses path.resolve + startsWith to prevent directory traversal. + * + * @param filePath - The path to check (will be resolved to absolute) + * @param boundary - The directory that must contain filePath + * @returns true if filePath is inside boundary (or equal to it) + */ +function isWithinDirectory(filePath: string, boundary: string): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedBoundary = path.resolve(boundary); + // Append separator to avoid prefix false-positives (e.g. /foo-bar matching /foo) + return ( + resolvedPath === resolvedBoundary || + resolvedPath.startsWith(`${resolvedBoundary}${path.sep}`) + ); +} + +let pluginManifestValidator: ReturnType | null = null; + +/** + * Loads and compiles the plugin-manifest JSON schema (cached). + * Returns the compiled validate function or null if the schema cannot be loaded. + */ +function getPluginManifestValidator(): ReturnType | null { + if (pluginManifestValidator) return pluginManifestValidator; + try { + const schemaRaw = fs.readFileSync(PLUGIN_MANIFEST_SCHEMA_PATH, "utf-8"); + const schema = JSON.parse(schemaRaw) as object; + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + pluginManifestValidator = ajv.compile(schema); + return pluginManifestValidator; + } catch (err) { + console.warn( + "Warning: Could not load plugin-manifest schema for validation:", + err instanceof Error ? err.message : err, + ); + return null; + } +} + +/** + * Validates a parsed JSON object against the plugin-manifest JSON schema. + * Returns the manifest if valid, or null and logs schema errors. + * + * @param obj - The parsed JSON object to validate + * @param sourcePath - Path to the manifest file (for warning messages) + * @returns A valid PluginManifest or null + */ +function validateManifestWithSchema( + obj: unknown, + sourcePath: string, +): PluginManifest | null { + if (!obj || typeof obj !== "object") { + console.warn(`Warning: Manifest at ${sourcePath} is not a valid object`); + return null; + } + + const validate = getPluginManifestValidator(); + if (!validate) { + // Schema not available (e.g. dev without build); fall back to basic shape check + const m = obj as Record; + if ( + typeof m.name === "string" && + m.name.length > 0 && + typeof m.displayName === "string" && + m.displayName.length > 0 && + typeof m.description === "string" && + m.description.length > 0 && + m.resources && + typeof m.resources === "object" && + Array.isArray((m.resources as { required?: unknown }).required) + ) { + return obj as PluginManifest; + } + console.warn(`Warning: Manifest at ${sourcePath} has invalid structure`); + return null; + } + + const valid = validate(obj); + if (valid) return obj as PluginManifest; + + const errors: ErrorObject[] = validate.errors ?? []; + const message = errors + .map( + (e: ErrorObject) => + ` ${e.instancePath || "/"} ${e.message}${e.params ? ` (${JSON.stringify(e.params)})` : ""}`, + ) + .join("\n"); + console.warn( + `Warning: Manifest at ${sourcePath} failed schema validation:\n${message}`, + ); + return null; +} + +/** + * Known packages that may contain AppKit plugins. + * Always scanned for manifests, even if not imported in the server file. + */ +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, + projectRoot?: string, +): string | null { + const resolved = path.resolve(serverFileDir, importSource); + + // Security: Reject paths that escape the project root + const boundary = projectRoot || serverFileDir; + if (!isWithinDirectory(resolved, boundary)) { + console.warn( + `Warning: Skipping import "${importSource}" — resolves outside the project directory`, + ); + return null; + } + + // Case 1: Import path is a directory with manifest.json + // e.g. ./plugins/my-plugin → ./plugins/my-plugin/manifest.json + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + 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, cwd); + if (!manifestPath) continue; + + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(content); + const manifest = validateManifestWithSchema(parsed, manifestPath); + if (!manifest) continue; + + 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 parsed = JSON.parse(content); + const manifest = validateManifestWithSchema(parsed, manifestPath); + if (manifest) { + manifests.push(manifest); + } + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + 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 = path.resolve(cwd, options.output || "appkit.plugins.json"); + + // Security: Reject output paths that escape the project root + if (!isWithinDirectory(outputPath, cwd)) { + console.error( + `Error: Output path "${options.output}" resolves outside the project directory.`, + ); + process.exit(1); + } + + console.log("Scanning for AppKit plugins...\n"); + + // 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)); + } +} + +/** Exported for testing: path boundary check, AST parsing. */ +export { isWithinDirectory, parseImports, parsePluginUsages }; + +export const pluginsSyncCommand = new Command("sync") + .description( + "Sync plugin manifests from installed packages into appkit.plugins.json", + ) + .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 a30260aa..54d8f583 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,4 +1,5 @@ import type express from "express"; +import type { JSONSchema7 } from "json-schema"; /** Base plugin interface. */ export interface BasePlugin { @@ -6,8 +7,6 @@ export interface BasePlugin { abortActiveOperations?(): void; - validateEnv(): void; - setup(): Promise; injectRoutes(router: express.Router): void; @@ -46,6 +45,10 @@ export interface PluginConfig { export type PluginPhase = "core" | "normal" | "deferred"; +/** + * Plugin constructor with required manifest declaration. + * All plugins must declare a manifest with their metadata and resource requirements. + */ export type PluginConstructor< C = BasePluginConfig, I extends BasePlugin = BasePlugin, @@ -54,8 +57,72 @@ export type PluginConstructor< ) => I) & { DEFAULT_CONFIG?: Record; phase?: PluginPhase; + /** + * Static manifest declaring plugin metadata and resource requirements. + * Required for all plugins. + */ + manifest: PluginManifest; + /** + * Optional runtime resource requirements based on config. + * Use this when resource requirements depend on plugin configuration. + */ + getResourceRequirements?(config: C): ResourceRequirement[]; }; +/** + * Manifest declaration for plugins (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: Omit[]; + optional: Omit[]; + }; + config?: { + schema: JSONSchema7; + }; + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} + +/** + * Defines a single field for a resource. + * Each field maps to its own environment variable and optional description. + * Single-value types use one key (e.g. id); multi-value types (database, secret) + * use multiple (e.g. instance_name, database_name or scope, key). + */ +export interface ResourceFieldEntry { + /** Environment variable name for this field */ + env: string; + /** Human-readable description for this field */ + description?: string; +} + +/** + * Resource requirement declaration (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface ResourceRequirement { + type: string; + alias: string; + /** Stable key for machine use (env naming, composite keys, app.yaml). */ + resourceKey: string; + description: string; + permission: string; + /** + * Map of field name to env and optional description. + * Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. + */ + fields: Record; + required: boolean; +} + export type ConfigFor = T extends { DEFAULT_CONFIG: infer D } ? D : T extends new ( diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json new file mode 100644 index 00000000..8f8c9feb --- /dev/null +++ b/packages/shared/src/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": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", + "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/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json new file mode 100644 index 00000000..f6bb5ef8 --- /dev/null +++ b/packages/shared/src/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/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index b1fdb9c5..98128e34 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -18,4 +18,14 @@ export default defineConfig({ exports: { devExports: "development", }, + copy: [ + { + from: "src/schemas/plugin-manifest.schema.json", + to: "dist/schemas/plugin-manifest.schema.json", + }, + { + from: "src/schemas/template-plugins.schema.json", + to: "dist/schemas/template-plugins.schema.json", + }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 587a1d24..4b598b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: '@types/express': specifier: ^4.17.25 version: 4.17.25 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/pg': specifier: ^8.15.6 version: 8.15.6 @@ -496,6 +499,12 @@ importers: '@ast-grep/napi': specifier: ^0.37.0 version: 0.37.0 + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) commander: specifier: ^12.1.0 version: 12.1.0 @@ -506,6 +515,9 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.23 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -3016,8 +3028,8 @@ packages: resolution: {integrity: sha512-Z7x2dZOmznihvdvCvLKMl+nswtOSVxS2H2ocar+U9xx6iMfTp0VGIrX6a4xB1v80IwOPC7dT1LXIJrY70Xu3Jw==} engines: {node: ^20.19.0 || >=22.12.0} - '@oxc-project/types@0.112.0': - resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-project/types@0.113.0': + resolution: {integrity: sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==} '@oxc-project/types@0.93.0': resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} @@ -3742,8 +3754,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + '@rolldown/binding-android-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -3754,8 +3766,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -3766,8 +3778,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.3': - resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.4': + resolution: {integrity: sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -3778,8 +3790,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': - resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.4': + resolution: {integrity: sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -3790,8 +3802,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': - resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': + resolution: {integrity: sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3802,8 +3814,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': + resolution: {integrity: sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3814,8 +3826,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': + resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3826,8 +3838,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': - resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': + resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3838,8 +3850,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': - resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': + resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3850,8 +3862,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': - resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': + resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3861,8 +3873,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': - resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': + resolution: {integrity: sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -3872,8 +3884,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': + resolution: {integrity: sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3890,8 +3902,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': - resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': + resolution: {integrity: sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3905,8 +3917,8 @@ packages: '@rolldown/pluginutils@1.0.0-beta.47': resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rolldown/pluginutils@1.0.0-rc.4': + resolution: {integrity: sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==} '@rollup/rollup-android-arm-eabi@4.52.4': resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} @@ -4910,6 +4922,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -9744,8 +9764,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-rc.3: - resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + rolldown@1.0.0-rc.4: + resolution: {integrity: sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -12687,7 +12707,7 @@ snapshots: '@babel/preset-env': 7.28.5(@babel/core@7.28.5) '@babel/preset-react': 7.28.5(@babel/core@7.28.5) '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@babel/runtime-corejs3': 7.28.4 '@babel/traverse': 7.28.5 '@docusaurus/logger': 3.9.2 @@ -14701,7 +14721,7 @@ snapshots: '@oxc-project/runtime@0.92.0': {} - '@oxc-project/types@0.112.0': {} + '@oxc-project/types@0.113.0': {} '@oxc-project/types@0.93.0': {} @@ -15449,61 +15469,61 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.3': + '@rolldown/binding-android-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-darwin-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + '@rolldown/binding-darwin-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-darwin-x64@1.0.0-beta.41': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.3': + '@rolldown/binding-darwin-x64@1.0.0-rc.4': optional: true '@rolldown/binding-freebsd-x64@1.0.0-beta.41': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + '@rolldown/binding-freebsd-x64@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-beta.41': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-beta.41': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': optional: true '@rolldown/binding-wasm32-wasi@1.0.0-beta.41': @@ -15511,7 +15531,7 @@ snapshots: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true @@ -15519,7 +15539,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': optional: true '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41': @@ -15528,7 +15548,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-beta.41': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': optional: true '@rolldown/pluginutils@1.0.0-beta.38': {} @@ -15537,7 +15557,7 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.47': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.0-rc.4': {} '@rollup/rollup-android-arm-eabi@4.52.4': optional: true @@ -16713,6 +16733,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -21906,7 +21930,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.103.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.0)' webpack: 5.103.0 @@ -21940,13 +21964,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react: 19.2.0 react-router: 5.3.4(react@19.2.0) react-router-dom@5.3.4(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -21957,7 +21981,7 @@ snapshots: react-router@5.3.4(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -22318,7 +22342,7 @@ snapshots: robust-predicates@3.0.2: {} - rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.16.11(rolldown@1.0.0-rc.4)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.5 @@ -22329,7 +22353,7 @@ snapshots: dts-resolver: 2.1.2 get-tsconfig: 4.12.0 magic-string: 0.30.19 - rolldown: 1.0.0-rc.3 + rolldown: 1.0.0-rc.4 optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -22393,24 +22417,24 @@ snapshots: '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.41 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.41 - rolldown@1.0.0-rc.3: + rolldown@1.0.0-rc.4: dependencies: - '@oxc-project/types': 0.112.0 - '@rolldown/pluginutils': 1.0.0-rc.3 + '@oxc-project/types': 0.113.0 + '@rolldown/pluginutils': 1.0.0-rc.4 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 - '@rolldown/binding-darwin-x64': 1.0.0-rc.3 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + '@rolldown/binding-android-arm64': 1.0.0-rc.4 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.4 + '@rolldown/binding-darwin-x64': 1.0.0-rc.4 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.4 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.4 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.4 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 rollup@4.52.4: dependencies: @@ -23060,8 +23084,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown: 1.0.0-rc.4 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.0-rc.4)(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.1 tinyglobby: 0.2.15 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/.env.tmpl b/template/.env.tmpl index 62f55187..31d0d997 100644 --- a/template/.env.tmpl +++ b/template/.env.tmpl @@ -1,4 +1,4 @@ -{{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} +{{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{.workspace_host}}{{end}} {{- if .dotenv}} {{.dotenv}} {{- end}} diff --git a/template/.gitignore.tmpl b/template/_gitignore similarity index 100% rename from template/.gitignore.tmpl rename to template/_gitignore 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..a4cace29 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,19 +16,18 @@ 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}} + host: {{.workspace_host}} {{if .target_variables}} variables: diff --git a/template/features/analytics/app_env.yml b/template/features/analytics/app_env.yml deleted file mode 100644 index 9228a9dd..00000000 --- a/template/features/analytics/app_env.yml +++ /dev/null @@ -1,2 +0,0 @@ - - name: DATABRICKS_WAREHOUSE_ID - valueFrom: warehouse diff --git a/template/features/analytics/bundle_resources.yml b/template/features/analytics/bundle_resources.yml deleted file mode 100644 index b3a631c0..00000000 --- a/template/features/analytics/bundle_resources.yml +++ /dev/null @@ -1,4 +0,0 @@ - - name: 'warehouse' - sql_warehouse: - id: ${var.warehouse_id} - permission: 'CAN_USE' diff --git a/template/features/analytics/bundle_variables.yml b/template/features/analytics/bundle_variables.yml deleted file mode 100644 index ac4fbf15..00000000 --- a/template/features/analytics/bundle_variables.yml +++ /dev/null @@ -1,2 +0,0 @@ - warehouse_id: - description: The ID of the warehouse to use diff --git a/template/features/analytics/dotenv.yml b/template/features/analytics/dotenv.yml deleted file mode 100644 index 7d17f13c..00000000 --- a/template/features/analytics/dotenv.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/template/features/analytics/dotenv_example.yml b/template/features/analytics/dotenv_example.yml deleted file mode 100644 index 1ae1aa74..00000000 --- a/template/features/analytics/dotenv_example.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID= diff --git a/template/features/analytics/target_variables.yml b/template/features/analytics/target_variables.yml deleted file mode 100644 index 0de7b63b..00000000 --- a/template/features/analytics/target_variables.yml +++ /dev/null @@ -1 +0,0 @@ - warehouse_id: {{.sql_warehouse_id}} diff --git a/template/package-lock.json b/template/package-lock.json index 84d74e11..6c5b2fbd 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -713,448 +713,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5888,9 +5446,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "license": "MIT", "peer": true, "funding": { @@ -8730,49 +8288,6 @@ "benchmarks" ] }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11592,12 +11107,12 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.17.2", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", - "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.10.1", + "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", @@ -11626,9 +11141,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", - "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -11955,9 +11470,9 @@ } }, "node_modules/react-day-picker": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz", - "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", "license": "MIT", "dependencies": { "@date-fns/tz": "^1.4.1", diff --git a/template/server/server.ts b/template/server/server.ts index da041927..e5f3b323 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,8 +1,7 @@ -import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; +import { createApp, {{.plugin_imports}} } from '@databricks/appkit'; createApp({ plugins: [ - server(), - {{.plugin_usage}}, + {{.plugin_usages}} ], }).catch(console.error);