diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 6dcb91c3..64de5830 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -69,9 +69,13 @@ class MyPlugin extends Plugin { resources.push({ type: ResourceType.DATABASE, alias: 'cache', + resourceKey: 'database', description: 'Cache storage for query results', permission: 'CAN_CONNECT_AND_CREATE', - env: 'DATABRICKS_DATABASE_ID', + fields: { + instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' }, + database_name: { env: 'DATABRICKS_CACHE_DB' }, + }, required: true // Mark as required at runtime }); } diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md index 13703835..612ca794 100644 --- a/docs/docs/api/appkit/Class.ResourceRegistry.md +++ b/docs/docs/api/appkit/Class.ResourceRegistry.md @@ -163,8 +163,8 @@ validate(): ValidationResult; Validates all registered resources against the environment. -Checks each resource's environment variable to determine if it's resolved. -Updates the `resolved` and `value` fields on each resource entry. +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. @@ -182,7 +182,7 @@ const registry = ResourceRegistry.getInstance(); const result = registry.validate(); if (!result.valid) { - console.error("Missing resources:", result.missing.map(r => r.env)); + console.error("Missing resources:", result.missing.map(r => Object.values(r.fields).map(f => f.env))); } ``` diff --git a/docs/docs/api/appkit/Function.getResourceRequirements.md b/docs/docs/api/appkit/Function.getResourceRequirements.md index 12ea5068..8feb69a5 100644 --- a/docs/docs/api/appkit/Function.getResourceRequirements.md +++ b/docs/docs/api/appkit/Function.getResourceRequirements.md @@ -4,9 +4,10 @@ function getResourceRequirements(plugin: PluginConstructor): { alias: string; description: string; - env?: string; + fields: Record; permission: ResourcePermission; required: boolean; + resourceKey: string; type: ResourceType; }[]; ``` diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md index 5d962219..f6559c80 100644 --- a/docs/docs/api/appkit/Interface.ResourceEntry.md +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -15,7 +15,7 @@ Extends ResourceRequirement with resolution state and plugin ownership. alias: string; ``` -Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. #### Inherited from @@ -37,18 +37,18 @@ Human-readable description of why this resource is needed *** -### env? +### fields ```ts -optional env: string; +fields: Record; ``` -Environment variable name where the resource ID/value should be provided -Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' +Map of field name to env and optional description. +Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. #### Inherited from -[`ResourceRequirement`](Interface.ResourceRequirement.md).[`env`](Interface.ResourceRequirement.md#env) +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`fields`](Interface.ResourceRequirement.md#fields) *** @@ -96,7 +96,21 @@ Whether this resource is required (true) or optional (false) resolved: boolean; ``` -Whether the resource has been resolved (environment variable found) +Whether the resource has been resolved (all field env vars set) + +*** + +### resourceKey + +```ts +resourceKey: string; +``` + +Stable key for machine use (env naming, composite keys, app.yaml). Required. + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`resourceKey`](Interface.ResourceRequirement.md#resourcekey) *** @@ -114,10 +128,10 @@ Type of Databricks resource required *** -### value? +### values? ```ts -optional value: string; +optional values: Record; ``` -The actual value of the resource (if resolved) +Resolved value per field name. Populated by validate() when all field env vars are set. diff --git a/docs/docs/api/appkit/Interface.ResourceFieldEntry.md b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md new file mode 100644 index 00000000..198334e4 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md @@ -0,0 +1,24 @@ +# Interface: ResourceFieldEntry + +Defines a single field for a resource. Each field has its own environment variable and optional description. +Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). + +## Properties + +### description? + +```ts +optional description: string; +``` + +Human-readable description for this field + +*** + +### env + +```ts +env: string; +``` + +Environment variable name for this field diff --git a/docs/docs/api/appkit/Interface.ResourceRequirement.md b/docs/docs/api/appkit/Interface.ResourceRequirement.md index 86893314..ed040a88 100644 --- a/docs/docs/api/appkit/Interface.ResourceRequirement.md +++ b/docs/docs/api/appkit/Interface.ResourceRequirement.md @@ -15,7 +15,7 @@ Can be defined statically in a manifest or dynamically via getResourceRequiremen alias: string; ``` -Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. *** @@ -29,14 +29,14 @@ Human-readable description of why this resource is needed *** -### env? +### fields ```ts -optional env: string; +fields: Record; ``` -Environment variable name where the resource ID/value should be provided -Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' +Map of field name to env and optional description. +Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. *** @@ -60,6 +60,16 @@ Whether this resource is required (true) or optional (false) *** +### resourceKey + +```ts +resourceKey: string; +``` + +Stable key for machine use (env naming, composite keys, app.yaml). Required. + +*** + ### type ```ts diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 00a54916..11282b99 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -36,6 +36,7 @@ plugin architecture, and React integration. | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | | [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. | | [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | +| [ResourceFieldEntry](Interface.ResourceFieldEntry.md) | Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). | | [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Configuration for streaming execution with default and user-scoped settings | | [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 8fce7996..9fa0c956 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -112,6 +112,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.ResourceEntry", label: "ResourceEntry" }, + { + type: "doc", + id: "api/appkit/Interface.ResourceFieldEntry", + label: "ResourceFieldEntry" + }, { type: "doc", id: "api/appkit/Interface.ResourceRequirement", diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index d1f93b74..aa3fd137 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -186,9 +186,33 @@ { "$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", "description", "permission"], + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], "properties": { "type": { "$ref": "#/$defs/resourceType" @@ -196,8 +220,14 @@ "alias": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin", - "examples": ["warehouse", "secrets", "vectorIndex"] + "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", @@ -207,11 +237,13 @@ "permission": { "$ref": "#/$defs/resourcePermission" }, - "env": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]*$", - "description": "Environment variable name where the resource ID should be provided", - "examples": ["DATABRICKS_WAREHOUSE_ID", "DATABRICKS_SECRET_SCOPE"] + "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/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json new file mode 100644 index 00000000..f6bb5ef8 --- /dev/null +++ b/docs/static/schemas/template-plugins.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "title": "AppKit Template Plugins Manifest", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins and their resource requirements.", + "type": "object", + "required": ["version", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "1.0", + "description": "Schema version for the template plugins manifest" + }, + "plugins": { + "type": "object", + "description": "Map of plugin name to plugin manifest with package source", + "additionalProperties": { + "$ref": "#/$defs/templatePlugin" + } + } + }, + "additionalProperties": false, + "$defs": { + "templatePlugin": { + "type": "object", + "required": [ + "name", + "displayName", + "description", + "package", + "resources" + ], + "description": "Plugin manifest with package source information", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "package": { + "type": "string", + "minLength": 1, + "description": "NPM package name that provides this plugin", + "examples": ["@databricks/appkit", "@my-org/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init. The user will only be prompted to configure its resources. When absent or false, the plugin is optional and the user can choose whether to include it." + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts index 76090f25..7c4a89ce 100644 --- a/packages/appkit/src/core/appkit.ts +++ b/packages/appkit/src/core/appkit.ts @@ -234,7 +234,7 @@ export class AppKit { type: r.type, alias: r.alias, plugin: r.plugin, - env: r.env, + envVars: Object.values(r.fields).map((f) => f.env), })), }, }); diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 96f72ba3..d2391273 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -37,6 +37,7 @@ export type { ConfigSchemaProperty, PluginManifest, ResourceEntry, + ResourceFieldEntry, ResourcePermission, ResourceRequirement, ValidationResult, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 3ef7a79f..4f60f195 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -128,9 +128,13 @@ const EXCLUDED_FROM_PROXY = new Set([ * resources.push({ * type: ResourceType.DATABASE, * alias: 'cache', + * resourceKey: 'database', * description: 'Cache storage for query results', * permission: 'CAN_CONNECT_AND_CREATE', - * env: 'DATABRICKS_DATABASE_ID', + * fields: { + * instance_name: { env: 'DATABRICKS_CACHE_INSTANCE' }, + * database_name: { env: 'DATABRICKS_CACHE_DB' }, + * }, * required: true // Mark as required at runtime * }); * } diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index 5ccc695f..7eb79313 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -7,10 +7,16 @@ "required": [ { "type": "sql_warehouse", - "alias": "warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", "description": "SQL Warehouse for executing analytics queries", "permission": "CAN_USE", - "env": "DATABRICKS_WAREHOUSE_ID" + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } } ], "optional": [] diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts index 09614593..393ef0f6 100644 --- a/packages/appkit/src/registry/resource-registry.ts +++ b/packages/appkit/src/registry/resource-registry.ts @@ -147,19 +147,8 @@ export class ResourceRegistry { } } - // Handle env variable merging - let env = existing.env; - if (newResource.env && newResource.env !== existing.env) { - // If env vars differ, prefer existing but note the conflict - if (existing.env) { - // Keep existing env, could log a warning here - env = existing.env; - } else { - env = newResource.env; - } - } else if (newResource.env) { - env = newResource.env; - } + // Prefer existing fields when both have them (same type+alias) + const fields = existing.fields ?? newResource.fields; return { ...existing, @@ -167,7 +156,7 @@ export class ResourceRegistry { permission, required, description, - env, + fields, }; } @@ -241,8 +230,8 @@ export class ResourceRegistry { /** * Validates all registered resources against the environment. * - * Checks each resource's environment variable to determine if it's resolved. - * Updates the `resolved` and `value` fields on each resource entry. + * 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. @@ -255,7 +244,7 @@ export class ResourceRegistry { * const result = registry.validate(); * * if (!result.valid) { - * console.error("Missing resources:", result.missing.map(r => r.env)); + * console.error("Missing resources:", result.missing.map(r => Object.values(r.fields).map(f => f.env))); * } * ``` */ @@ -263,48 +252,43 @@ export class ResourceRegistry { const missing: ResourceEntry[] = []; for (const entry of this.resources.values()) { - if (entry.env) { - const value = process.env[entry.env]; - if (value) { - entry.resolved = true; - entry.value = value; - logger.debug( - "Resource %s:%s resolved from %s", - entry.type, - entry.alias, - entry.env, - ); + 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 { - entry.resolved = false; - entry.value = undefined; - - // Only required resources affect validation - if (entry.required) { - missing.push(entry); - logger.debug( - "Required resource %s:%s missing (env: %s)", - entry.type, - entry.alias, - entry.env, - ); - } else { - logger.debug( - "Optional resource %s:%s not configured (env: %s)", - entry.type, - entry.alias, - entry.env, - ); - } + allSet = false; } - } else { - // Resources without env vars are considered resolved - // (they may be provided through other means like config) + } + if (allSet) { entry.resolved = true; + entry.values = values; logger.debug( - "Resource %s:%s has no env var, marking as resolved", + "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(", "), + ); + } } } @@ -327,7 +311,8 @@ export class ResourceRegistry { } const lines = missing.map((entry) => { - const envHint = entry.env ? ` (set ${entry.env})` : ""; + const envVars = Object.values(entry.fields).map((f) => f.env); + const envHint = ` (set ${envVars.join(", ")})`; return ` - ${entry.type}:${entry.alias} [${entry.plugin}]${envHint}`; }); diff --git a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json index d1f93b74..aa3fd137 100644 --- a/packages/appkit/src/registry/schemas/plugin-manifest.schema.json +++ b/packages/appkit/src/registry/schemas/plugin-manifest.schema.json @@ -186,9 +186,33 @@ { "$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", "description", "permission"], + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], "properties": { "type": { "$ref": "#/$defs/resourceType" @@ -196,8 +220,14 @@ "alias": { "type": "string", "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin", - "examples": ["warehouse", "secrets", "vectorIndex"] + "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", @@ -207,11 +237,13 @@ "permission": { "$ref": "#/$defs/resourcePermission" }, - "env": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]*$", - "description": "Environment variable name where the resource ID should be provided", - "examples": ["DATABRICKS_WAREHOUSE_ID", "DATABRICKS_SECRET_SCOPE"] + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." } }, "additionalProperties": false diff --git a/packages/appkit/src/registry/schemas/template-plugins.schema.json b/packages/appkit/src/registry/schemas/template-plugins.schema.json new file mode 100644 index 00000000..f6bb5ef8 --- /dev/null +++ b/packages/appkit/src/registry/schemas/template-plugins.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "title": "AppKit Template Plugins Manifest", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins and their resource requirements.", + "type": "object", + "required": ["version", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "1.0", + "description": "Schema version for the template plugins manifest" + }, + "plugins": { + "type": "object", + "description": "Map of plugin name to plugin manifest with package source", + "additionalProperties": { + "$ref": "#/$defs/templatePlugin" + } + } + }, + "additionalProperties": false, + "$defs": { + "templatePlugin": { + "type": "object", + "required": [ + "name", + "displayName", + "description", + "package", + "resources" + ], + "description": "Plugin manifest with package source information", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.", + "examples": ["analytics", "server", "my-custom-plugin"] + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Human-readable display name for UI and CLI", + "examples": ["Analytics Plugin", "Server Plugin"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Brief description of what the plugin does", + "examples": ["SQL query execution against Databricks SQL Warehouses"] + }, + "package": { + "type": "string", + "minLength": 1, + "description": "NPM package name that provides this plugin", + "examples": ["@databricks/appkit", "@my-org/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init. The user will only be prompted to configure its resources. When absent or false, the plugin is optional and the user can choose whether to include it." + }, + "resources": { + "type": "object", + "required": ["required", "optional"], + "description": "Databricks resource requirements for this plugin", + "properties": { + "required": { + "type": "array", + "description": "Resources that must be available for the plugin to function", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + }, + "optional": { + "type": "array", + "description": "Resources that enhance functionality but are not mandatory", + "items": { + "$ref": "#/$defs/resourceRequirement" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "resourceType": { + "type": "string", + "enum": [ + "secret", + "job", + "sql_warehouse", + "serving_endpoint", + "volume", + "vector_search_index", + "uc_function", + "uc_connection", + "database", + "genie_space", + "experiment", + "app" + ], + "description": "Type of Databricks resource" + }, + "resourcePermission": { + "type": "string", + "description": "Permission level required for the resource. Valid values depend on resource type.", + "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + }, + "resourceFieldEntry": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field", + "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "required": [ + "type", + "alias", + "resourceKey", + "description", + "permission", + "fields" + ], + "properties": { + "type": { + "$ref": "#/$defs/resourceType" + }, + "alias": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Unique alias for this resource within the plugin (UI/display)", + "examples": ["SQL Warehouse", "Secret", "Vector search index"] + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9_]*$", + "description": "Stable key for machine use (env naming, composite keys, app.yaml).", + "examples": ["sql-warehouse", "database", "secret"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "$ref": "#/$defs/resourcePermission" + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts index ce587758..6b328af2 100644 --- a/packages/appkit/src/registry/tests/integration.test.ts +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -27,15 +27,19 @@ describe("Manifest Loader Integration", () => { expect(manifest?.displayName).toBe("Analytics Plugin"); }); - it("should require SQL Warehouse", () => { + it("should require SQL Warehouse and list optional cache database", () => { const resources = getResourceRequirements(AnalyticsPlugin); expect(resources).toHaveLength(1); - expect(resources[0]).toMatchObject({ + + const required = resources.find((r) => r.required); + expect(required).toBeDefined(); + + expect(required).toMatchObject({ type: ResourceType.SQL_WAREHOUSE, - alias: "warehouse", + resourceKey: "sql-warehouse", required: true, permission: "CAN_USE", - env: "DATABRICKS_WAREHOUSE_ID", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, }); }); diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts index d21904da..4e18170e 100644 --- a/packages/appkit/src/registry/tests/manifest-loader.test.ts +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -21,9 +21,12 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "sql-warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { + id: { env: "TEST_WAREHOUSE_ID", description: "Warehouse ID" }, + }, }, ], optional: [], @@ -196,10 +199,14 @@ describe("Manifest Loader", () => { optional: [ { type: ResourceType.SECRET, - alias: "secrets", + alias: "Secret", + resourceKey: "secret", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -237,9 +244,10 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, }, ], optional: [], @@ -272,9 +280,13 @@ describe("Manifest Loader", () => { { type: ResourceType.SECRET, alias: "secrets", + resourceKey: "secrets", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -305,18 +317,23 @@ describe("Manifest Loader", () => { { type: ResourceType.SQL_WAREHOUSE, alias: "warehouse", + resourceKey: "warehouse", description: "Test warehouse", permission: "CAN_USE", - env: "TEST_WAREHOUSE_ID", + fields: { id: { env: "TEST_WAREHOUSE_ID" } }, }, ], optional: [ { type: ResourceType.SECRET, alias: "secrets", + resourceKey: "secrets", description: "Optional secrets", permission: "READ", - env: "TEST_SECRET_SCOPE", + fields: { + scope: { env: "TEST_SECRET_SCOPE" }, + key: { env: "TEST_SECRET_KEY" }, + }, }, ], }, @@ -334,6 +351,60 @@ describe("Manifest Loader", () => { expect(resources[1].required).toBe(false); }); + it("should return resources with fields for multi-field types", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Database for caching", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.DATABASE, + alias: "cache", + required: true, + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }); + }); + it("should return empty array for plugin with no resources", () => { const mockManifest: PluginManifest = { name: "test-plugin", diff --git a/packages/appkit/src/registry/tests/resource-registry.test.ts b/packages/appkit/src/registry/tests/resource-registry.test.ts new file mode 100644 index 00000000..1abf5853 --- /dev/null +++ b/packages/appkit/src/registry/tests/resource-registry.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ResourceRegistry } from "../resource-registry"; +import { ResourceType } from "../types"; + +describe("ResourceRegistry", () => { + beforeEach(() => { + ResourceRegistry.resetInstance(); + }); + + afterEach(() => { + ResourceRegistry.resetInstance(); + }); + + describe("register and merge with fields", () => { + it("should register a multi-field resource (database)", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Database for caching", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }, + }); + + const entry = registry.get("database", "cache"); + expect(entry).toBeDefined(); + expect(entry?.fields).toEqual({ + instance_name: { + env: "DATABRICKS_CACHE_INSTANCE", + description: "Lakebase instance name", + }, + database_name: { + env: "DATABRICKS_CACHE_DB", + description: "Database name", + }, + }); + }); + + it("should merge resources and prefer existing fields", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE_A", description: "Scope" }, + key: { env: "SECRET_KEY_A", description: "Key" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: false, + fields: { + scope: { env: "SECRET_SCOPE_B", description: "Scope" }, + key: { env: "SECRET_KEY_B", description: "Key" }, + }, + }); + + const entry = registry.get("secret", "creds"); + expect(entry?.fields).toEqual({ + scope: { env: "SECRET_SCOPE_A", description: "Scope" }, + key: { env: "SECRET_KEY_A", description: "Key" }, + }); + expect(entry?.plugin).toContain("plugin-a"); + expect(entry?.plugin).toContain("plugin-b"); + }); + + it("should merge single-value resources (fields with one key)", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("plugin-a", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + registry.register("plugin-b", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: false, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + const entry = registry.get("sql_warehouse", "warehouse"); + expect(entry?.fields).toEqual({ + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }); + }); + }); + + describe("validate with fields", () => { + const CACHE_INSTANCE = "DATABRICKS_CACHE_INSTANCE"; + const CACHE_DB = "DATABRICKS_CACHE_DB"; + + it("should resolve multi-field resource when all env vars are set", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + const orig = process.env[CACHE_INSTANCE]; + const origDb = process.env[CACHE_DB]; + process.env[CACHE_INSTANCE] = "my-instance"; + process.env[CACHE_DB] = "my_db"; + try { + const result = registry.validate(); + expect(result.valid).toBe(true); + expect(result.missing).toHaveLength(0); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(true); + expect(entry?.values).toEqual({ + instance_name: "my-instance", + database_name: "my_db", + }); + } finally { + if (orig !== undefined) process.env[CACHE_INSTANCE] = orig; + else delete process.env[CACHE_INSTANCE]; + if (origDb !== undefined) process.env[CACHE_DB] = origDb; + else delete process.env[CACHE_DB]; + } + }); + + it("should mark multi-field resource missing when any env var is unset", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + delete process.env[CACHE_INSTANCE]; + delete process.env[CACHE_DB]; + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + expect(result.missing[0].type).toBe("database"); + expect(result.missing[0].alias).toBe("cache"); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toBeUndefined(); + }); + + it("should mark multi-field resource missing when only one env var is set", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.DATABASE, + alias: "cache", + resourceKey: "cache", + description: "Cache database", + permission: "CAN_CONNECT_AND_CREATE", + required: true, + fields: { + instance_name: { env: CACHE_INSTANCE }, + database_name: { env: CACHE_DB }, + }, + }); + + process.env[CACHE_INSTANCE] = "my-instance"; + delete process.env[CACHE_DB]; + + const result = registry.validate(); + expect(result.valid).toBe(false); + expect(result.missing).toHaveLength(1); + const entry = registry.get("database", "cache"); + expect(entry?.resolved).toBe(false); + expect(entry?.values).toEqual({ instance_name: "my-instance" }); + }); + }); + + describe("formatMissingResources with fields", () => { + it("should list field env vars for multi-field missing resources", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SECRET, + alias: "creds", + resourceKey: "creds", + description: "Credentials", + permission: "READ", + required: true, + fields: { + scope: { env: "SECRET_SCOPE" }, + key: { env: "SECRET_KEY" }, + }, + }); + + delete process.env.SECRET_SCOPE; + delete process.env.SECRET_KEY; + const result = registry.validate(); + expect(result.valid).toBe(false); + + const formatted = ResourceRegistry.formatMissingResources(result.missing); + expect(formatted).toContain("secret:creds"); + expect(formatted).toContain("SECRET_SCOPE"); + expect(formatted).toContain("SECRET_KEY"); + }); + + it("should list field env vars for single-value missing resources", () => { + const registry = ResourceRegistry.getInstance(); + registry.register("analytics", { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + resourceKey: "warehouse", + description: "Warehouse", + permission: "CAN_USE", + required: true, + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }); + + delete process.env.DATABRICKS_WAREHOUSE_ID; + const result = registry.validate(); + const formatted = ResourceRegistry.formatMissingResources(result.missing); + expect(formatted).toContain("DATABRICKS_WAREHOUSE_ID"); + }); + }); +}); diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts index 30ad728e..c9716fb5 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -113,6 +113,17 @@ export type ResourcePermission = | ExperimentPermission | AppPermission; +/** + * Defines a single field for a resource. Each field has its own environment variable and optional description. + * Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). + */ +export interface ResourceFieldEntry { + /** Environment variable name for this field */ + env: string; + /** Human-readable description for this field */ + description?: string; +} + /** * Declares a resource requirement for a plugin. * Can be defined statically in a manifest or dynamically via getResourceRequirements(). @@ -121,9 +132,12 @@ export interface ResourceRequirement { /** Type of Databricks resource required */ type: ResourceType; - /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') */ + /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets'). Used for UI/display. */ alias: string; + /** Stable key for machine use (env naming, composite keys, app.yaml). Required. */ + resourceKey: string; + /** Human-readable description of why this resource is needed */ description: string; @@ -131,10 +145,10 @@ export interface ResourceRequirement { permission: ResourcePermission; /** - * Environment variable name where the resource ID/value should be provided - * Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + * Map of field name to env and optional description. + * Single-value types use one key (e.g. id); multi-value (database, secret) use multiple keys. */ - env?: string; + fields: Record; /** Whether this resource is required (true) or optional (false) */ required: boolean; @@ -148,11 +162,11 @@ export interface ResourceEntry extends ResourceRequirement { /** Plugin(s) that require this resource (comma-separated if multiple) */ plugin: string; - /** Whether the resource has been resolved (environment variable found) */ + /** Whether the resource has been resolved (all field env vars set) */ resolved: boolean; - /** The actual value of the resource (if resolved) */ - value?: string; + /** Resolved value per field name. Populated by validate() when all field env vars are set. */ + values?: Record; } /** diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index fb6cafe8..ad8c46be 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -51,6 +51,11 @@ export default defineConfig([ from: "src/registry/schemas/plugin-manifest.schema.json", to: "dist/registry/schemas/plugin-manifest.schema.json", }, + // JSON Schema for template plugins manifest + { + from: "src/registry/schemas/template-plugins.schema.json", + to: "dist/registry/schemas/template-plugins.schema.json", + }, ], }, ]); diff --git a/packages/shared/bin/appkit.js b/packages/shared/bin/appkit.js old mode 100644 new mode 100755 diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugins-sync.ts new file mode 100644 index 00000000..7b0cad3d --- /dev/null +++ b/packages/shared/src/cli/commands/plugins-sync.ts @@ -0,0 +1,505 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Lang, parse, type SgNode } from "@ast-grep/napi"; +import { Command } from "commander"; + +/** + * Field entry in a resource requirement (env var + optional description) + */ +interface ResourceFieldEntry { + env: string; + description?: string; +} + +/** + * Resource requirement as defined in plugin manifests. + * Uses fields (single key e.g. id, or multiple e.g. instance_name/database_name, scope/key). + */ +interface ResourceRequirement { + type: string; + alias: string; + resourceKey: string; + description: string; + permission: string; + fields: Record; +} + +/** + * Plugin manifest structure (from SDK plugin manifest.json files) + */ +interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: ResourceRequirement[]; + optional: ResourceRequirement[]; + }; + config?: { schema: unknown }; +} + +/** + * Plugin entry in the template manifest (includes package source) + */ +interface TemplatePlugin extends Omit { + package: string; + /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ + requiredByTemplate?: boolean; +} + +/** + * Template plugins manifest structure + */ +interface TemplatePluginsManifest { + $schema: string; + version: string; + plugins: Record; +} + +/** + * Known packages that may contain AppKit plugins. + * Always scanned for manifests, even if not imported in the server file. + */ +const KNOWN_PLUGIN_PACKAGES = ["@databricks/appkit"]; + +/** + * Candidate paths for the server entry file, relative to cwd. + * Checked in order; the first that exists is used. + */ +const SERVER_FILE_CANDIDATES = ["server/server.ts"]; + +/** + * Find the server entry file by checking candidate paths in order. + * + * @param cwd - Current working directory + * @returns Absolute path to the server file, or null if none found + */ +function findServerFile(cwd: string): string | null { + for (const candidate of SERVER_FILE_CANDIDATES) { + const fullPath = path.join(cwd, candidate); + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + return null; +} + +/** + * Represents a single named import extracted from the server file. + */ +interface ParsedImport { + /** The imported name (or local alias if renamed) */ + name: string; + /** The original exported name (differs from name when using `import { foo as bar }`) */ + originalName: string; + /** The module specifier (package name or relative path) */ + source: string; +} + +/** + * Extract all named imports from the AST root using structural node traversal. + * Handles single/double quotes, multiline imports, and aliased imports. + * + * @param root - AST root node + * @returns Array of parsed imports with name, original name, and source + */ +function parseImports(root: SgNode): ParsedImport[] { + const imports: ParsedImport[] = []; + + // Find all import_statement nodes in the AST + const importStatements = root.findAll({ + rule: { kind: "import_statement" }, + }); + + for (const stmt of importStatements) { + // Extract the module specifier (the string node, e.g. '@databricks/appkit') + const sourceNode = stmt.find({ rule: { kind: "string" } }); + if (!sourceNode) continue; + + // Strip surrounding quotes from the string node text + const source = sourceNode.text().replace(/^['"]|['"]$/g, ""); + + // Find named_imports block: { createApp, analytics, server } + const namedImports = stmt.find({ rule: { kind: "named_imports" } }); + if (!namedImports) continue; + + // Extract each import_specifier + const specifiers = namedImports.findAll({ + rule: { kind: "import_specifier" }, + }); + + for (const specifier of specifiers) { + const children = specifier.children(); + if (children.length >= 3) { + // Aliased import: `foo as bar` — children are [name, "as", alias] + const originalName = children[0].text(); + const localName = children[children.length - 1].text(); + imports.push({ name: localName, originalName, source }); + } else { + // Simple import: `foo` + const name = specifier.text(); + imports.push({ name, originalName: name, source }); + } + } + } + + return imports; +} + +/** + * Extract local names of plugins actually used in the `plugins: [...]` array + * passed to `createApp()`. Uses structural AST traversal to find `pair` nodes + * with key "plugins" and array values containing call expressions. + * + * @param root - AST root node + * @returns Set of local variable names used as plugin calls in the plugins array + */ +function parsePluginUsages(root: SgNode): Set { + const usedNames = new Set(); + + // Find all property pairs in the AST + const pairs = root.findAll({ rule: { kind: "pair" } }); + + for (const pair of pairs) { + // Check if the property key is "plugins" + const key = pair.find({ rule: { kind: "property_identifier" } }); + if (!key || key.text() !== "plugins") continue; + + // Find the array value + const arrayNode = pair.find({ rule: { kind: "array" } }); + if (!arrayNode) continue; + + // Iterate direct children of the array to find call expressions + for (const child of arrayNode.children()) { + if (child.kind() === "call_expression") { + // The callee is the first child (the identifier being called) + const callee = child.children()[0]; + if (callee?.kind() === "identifier") { + usedNames.add(callee.text()); + } + } + } + } + + return usedNames; +} + +/** + * File extensions to try when resolving a relative import to a file path. + */ +const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; + +/** + * Resolve a relative import source to the plugin directory containing a manifest.json. + * Follows the convention that plugins live in their own directory with a manifest.json. + * + * Resolution strategy: + * 1. If the import path is a directory, look for manifest.json directly in it + * 2. If the import path + extension is a file, look for manifest.json in its parent directory + * 3. If the import path is a directory with an index file, look for manifest.json in that directory + * + * @param importSource - The relative import specifier (e.g. "./plugins/my-plugin") + * @param serverFileDir - Absolute path to the directory containing the server file + * @returns Absolute path to manifest.json, or null if not found + */ +function resolveLocalManifest( + importSource: string, + serverFileDir: string, +): string | null { + const resolved = path.resolve(serverFileDir, importSource); + + // Case 1: Import path is a directory with manifest.json + // e.g. ./plugins/my-plugin → ./plugins/my-plugin/manifest.json + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + const manifestPath = path.join(resolved, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + } + + // Case 2: Import path + extension resolves to a file + // e.g. ./plugins/my-plugin → ./plugins/my-plugin.ts + // Look for manifest.json in the same directory + for (const ext of RESOLVE_EXTENSIONS) { + const filePath = `${resolved}${ext}`; + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const dir = path.dirname(filePath); + const manifestPath = path.join(dir, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + break; + } + } + + // Case 3: Import path is a directory with an index file + // e.g. ./plugins/my-plugin → ./plugins/my-plugin/index.ts + for (const ext of RESOLVE_EXTENSIONS) { + const indexPath = path.join(resolved, `index${ext}`); + if (fs.existsSync(indexPath)) { + const manifestPath = path.join(resolved, "manifest.json"); + if (fs.existsSync(manifestPath)) return manifestPath; + break; + } + } + + return null; +} + +/** + * Discover plugin manifests from local (relative) imports in the server file. + * Resolves each relative import to a directory and looks for manifest.json. + * + * @param relativeImports - Parsed imports with relative sources (starting with . or /) + * @param serverFileDir - Absolute path to the directory containing the server file + * @param cwd - Current working directory (for computing relative paths in output) + * @returns Map of plugin name to template plugin entry for local plugins + */ +function discoverLocalPlugins( + relativeImports: ParsedImport[], + serverFileDir: string, + cwd: string, +): TemplatePluginsManifest["plugins"] { + const plugins: TemplatePluginsManifest["plugins"] = {}; + + for (const imp of relativeImports) { + const manifestPath = resolveLocalManifest(imp.source, serverFileDir); + if (!manifestPath) continue; + + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as PluginManifest; + const relativePath = path.relative(cwd, path.dirname(manifestPath)); + + plugins[manifest.name] = { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: `./${relativePath}`, + resources: manifest.resources, + }; + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + error instanceof Error ? error.message : error, + ); + } + } + + return plugins; +} + +/** + * Discover plugin manifests from a package's dist folder. + * Looks for manifest.json files in dist/plugins/{plugin-name}/ directories. + * + * @param packagePath - Path to the package in node_modules + * @returns Array of plugin manifests found in the package + */ +function discoverPluginManifests(packagePath: string): PluginManifest[] { + const pluginsDir = path.join(packagePath, "dist", "plugins"); + const manifests: PluginManifest[] = []; + + if (!fs.existsSync(pluginsDir)) { + return manifests; + } + + const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const manifestPath = path.join(pluginsDir, entry.name, "manifest.json"); + if (fs.existsSync(manifestPath)) { + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const manifest = JSON.parse(content) as PluginManifest; + manifests.push(manifest); + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + error instanceof Error ? error.message : error, + ); + } + } + } + } + + return manifests; +} + +/** + * Scan node_modules for packages with plugin manifests. + * + * @param cwd - Current working directory to search from + * @param packages - Set of npm package names to scan for plugin manifests + * @returns Map of plugin name to template plugin entry + */ +function scanForPlugins( + cwd: string, + packages: Iterable, +): TemplatePluginsManifest["plugins"] { + const plugins: TemplatePluginsManifest["plugins"] = {}; + + for (const packageName of packages) { + const packagePath = path.join(cwd, "node_modules", packageName); + if (!fs.existsSync(packagePath)) { + continue; + } + + const manifests = discoverPluginManifests(packagePath); + for (const manifest of manifests) { + // Convert to template plugin format (exclude config schema) + plugins[manifest.name] = { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: packageName, + resources: manifest.resources, + }; + } + } + + return plugins; +} + +/** + * Run the plugins sync command. + * Parses the server entry file to discover which packages to scan for plugin + * manifests, then marks plugins that are actually used in the `plugins: [...]` + * array as requiredByTemplate. + */ +function runPluginsSync(options: { write?: boolean; output?: string }) { + const cwd = process.cwd(); + const outputPath = options.output || path.join(cwd, "appkit.plugins.json"); + + console.log("Scanning for AppKit plugins...\n"); + + // Step 1: Parse server file to discover imports and plugin usages + const serverFile = findServerFile(cwd); + let serverImports: ParsedImport[] = []; + let pluginUsages = new Set(); + + if (serverFile) { + const relativePath = path.relative(cwd, serverFile); + console.log(`Server entry file: ${relativePath}`); + + const content = fs.readFileSync(serverFile, "utf-8"); + const lang = serverFile.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript; + const ast = parse(lang, content); + const root = ast.root(); + + serverImports = parseImports(root); + pluginUsages = parsePluginUsages(root); + } else { + console.log( + "No server entry file found. Checked:", + SERVER_FILE_CANDIDATES.join(", "), + ); + } + + // Step 2: Split imports into npm packages and local (relative) imports + const npmImports = serverImports.filter( + (i) => !i.source.startsWith(".") && !i.source.startsWith("/"), + ); + const localImports = serverImports.filter( + (i) => i.source.startsWith(".") || i.source.startsWith("/"), + ); + + // Step 3: Scan npm packages for plugin manifests + const npmPackages = new Set([ + ...KNOWN_PLUGIN_PACKAGES, + ...npmImports.map((i) => i.source), + ]); + const plugins = scanForPlugins(cwd, npmPackages); + + // Step 4: Discover local plugin manifests from relative imports + if (serverFile && localImports.length > 0) { + const serverFileDir = path.dirname(serverFile); + const localPlugins = discoverLocalPlugins(localImports, serverFileDir, cwd); + Object.assign(plugins, localPlugins); + } + + const pluginCount = Object.keys(plugins).length; + + if (pluginCount === 0) { + console.log("No plugins found."); + console.log("\nMake sure you have plugin packages installed:"); + for (const pkg of npmPackages) { + console.log(` - ${pkg}`); + } + process.exit(1); + } + + // Step 5: Mark plugins that are imported AND used in the plugins array as mandatory. + // For npm imports, match by package name + plugin name. + // For local imports, resolve both paths to absolute and compare. + const serverFileDir = serverFile ? path.dirname(serverFile) : cwd; + + for (const imp of serverImports) { + if (!pluginUsages.has(imp.name)) continue; + + const isLocal = imp.source.startsWith(".") || imp.source.startsWith("/"); + let plugin: TemplatePlugin | undefined; + + if (isLocal) { + // Resolve the import source to an absolute path from the server file directory + const resolvedImportDir = path.resolve(serverFileDir, imp.source); + plugin = Object.values(plugins).find((p) => { + if (!p.package.startsWith(".")) return false; + const resolvedPluginDir = path.resolve(cwd, p.package); + return ( + resolvedPluginDir === resolvedImportDir && p.name === imp.originalName + ); + }); + } else { + // npm import: direct string comparison + plugin = Object.values(plugins).find( + (p) => p.package === imp.source && p.name === imp.originalName, + ); + } + + if (plugin) { + plugin.requiredByTemplate = true; + } + } + + console.log(`\nFound ${pluginCount} plugin(s):`); + for (const [name, manifest] of Object.entries(plugins)) { + const resourceCount = + manifest.resources.required.length + manifest.resources.optional.length; + const resourceInfo = + resourceCount > 0 ? ` [${resourceCount} resource(s)]` : ""; + const mandatoryTag = manifest.requiredByTemplate ? " (mandatory)" : ""; + console.log( + ` ${manifest.requiredByTemplate ? "●" : "○"} ${manifest.displayName} (${name}) from ${manifest.package}${resourceInfo}${mandatoryTag}`, + ); + } + + const templateManifest: TemplatePluginsManifest = { + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins, + }; + + if (options.write) { + fs.writeFileSync( + outputPath, + `${JSON.stringify(templateManifest, null, 2)}\n`, + ); + console.log(`\n✓ Wrote ${outputPath}`); + } else { + console.log("\nTo write the manifest, run:"); + console.log(" npx appkit plugins sync --write\n"); + console.log("Preview:"); + console.log("─".repeat(60)); + console.log(JSON.stringify(templateManifest, null, 2)); + console.log("─".repeat(60)); + } +} + +export const pluginsSyncCommand = new Command("sync") + .description( + "Sync plugin manifests from installed packages into appkit.plugins.json", + ) + .option("-w, --write", "Write the manifest file") + .option( + "-o, --output ", + "Output file path (default: ./appkit.plugins.json)", + ) + .action(runPluginsSync); diff --git a/packages/shared/src/cli/commands/plugins.ts b/packages/shared/src/cli/commands/plugins.ts new file mode 100644 index 00000000..ff1de368 --- /dev/null +++ b/packages/shared/src/cli/commands/plugins.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { pluginsSyncCommand } from "./plugins-sync.js"; + +/** + * Parent command for plugin management operations. + * Subcommands: + * - sync: Aggregate plugin manifests into appkit.plugins.json + * + * Future subcommands may include: + * - add: Add a plugin to an existing project + * - remove: Remove a plugin from a project + * - list: List available plugins + */ +export const pluginsCommand = new Command("plugins") + .description("Plugin management commands") + .addCommand(pluginsSyncCommand); diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 3b3c0293..23a19a53 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -7,6 +7,7 @@ import { Command } from "commander"; import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; +import { pluginsCommand } from "./commands/plugins.js"; import { setupCommand } from "./commands/setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -24,5 +25,6 @@ cmd.addCommand(setupCommand); cmd.addCommand(generateTypesCommand); cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); +cmd.addCommand(pluginsCommand); cmd.parse(); diff --git a/template/.env.example.tmpl b/template/.env.example.tmpl index c8b5c441..0a7c80ec 100644 --- a/template/.env.example.tmpl +++ b/template/.env.example.tmpl @@ -3,5 +3,5 @@ DATABRICKS_HOST=https://... {{.dotenv_example}} {{- end}} DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME=minimal +DATABRICKS_APP_NAME={{.project_name}} FLASK_RUN_HOST=0.0.0.0 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json new file mode 100644 index 00000000..67f3874a --- /dev/null +++ b/template/appkit.plugins.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "version": "1.0", + "plugins": { + "analytics": { + "name": "analytics", + "displayName": "Analytics Plugin", + "description": "SQL query execution against Databricks SQL Warehouses", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "sql_warehouse", + "alias": "SQL Warehouse", + "resourceKey": "sql-warehouse", + "description": "SQL Warehouse for executing analytics queries", + "permission": "CAN_USE", + "fields": { + "id": { + "env": "DATABRICKS_WAREHOUSE_ID", + "description": "SQL Warehouse ID" + } + } + } + ], + "optional": [] + } + }, + "server": { + "name": "server", + "displayName": "Server Plugin", + "description": "HTTP server with Express, static file serving, and Vite dev mode support", + "package": "@databricks/appkit", + "requiredByTemplate": true, + "resources": { + "required": [], + "optional": [] + } + } + } +} diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index cdfa3fe0..74ff0455 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -1,9 +1,9 @@ bundle: name: {{.project_name}} -{{if .bundle_variables}} +{{if .variables}} variables: -{{.bundle_variables}} +{{.variables}} {{- end}} resources: @@ -16,16 +16,15 @@ resources: # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files # user_api_scopes: # - sql -{{if .bundle_resources}} +{{if .resources}} # The resources which this app has access to. resources: -{{.bundle_resources}} +{{.resources}} {{- end}} targets: default: - # mode: production default: true workspace: host: {{workspace_host}} diff --git a/template/features/analytics/app_env.yml b/template/features/analytics/app_env.yml deleted file mode 100644 index 9228a9dd..00000000 --- a/template/features/analytics/app_env.yml +++ /dev/null @@ -1,2 +0,0 @@ - - name: DATABRICKS_WAREHOUSE_ID - valueFrom: warehouse diff --git a/template/features/analytics/bundle_resources.yml b/template/features/analytics/bundle_resources.yml deleted file mode 100644 index b3a631c0..00000000 --- a/template/features/analytics/bundle_resources.yml +++ /dev/null @@ -1,4 +0,0 @@ - - name: 'warehouse' - sql_warehouse: - id: ${var.warehouse_id} - permission: 'CAN_USE' diff --git a/template/features/analytics/bundle_variables.yml b/template/features/analytics/bundle_variables.yml deleted file mode 100644 index ac4fbf15..00000000 --- a/template/features/analytics/bundle_variables.yml +++ /dev/null @@ -1,2 +0,0 @@ - warehouse_id: - description: The ID of the warehouse to use diff --git a/template/features/analytics/dotenv.yml b/template/features/analytics/dotenv.yml deleted file mode 100644 index 7d17f13c..00000000 --- a/template/features/analytics/dotenv.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/template/features/analytics/dotenv_example.yml b/template/features/analytics/dotenv_example.yml deleted file mode 100644 index 1ae1aa74..00000000 --- a/template/features/analytics/dotenv_example.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID= diff --git a/template/features/analytics/target_variables.yml b/template/features/analytics/target_variables.yml deleted file mode 100644 index 0de7b63b..00000000 --- a/template/features/analytics/target_variables.yml +++ /dev/null @@ -1 +0,0 @@ - warehouse_id: {{.sql_warehouse_id}} diff --git a/template/package-lock.json b/template/package-lock.json index 6378b037..a10e495a 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -712,448 +712,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5887,9 +5445,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "license": "MIT", "peer": true, "funding": { @@ -8720,49 +8278,6 @@ "benchmarks" ] }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11582,12 +11097,12 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.17.2", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", - "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.10.1", + "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", @@ -11616,9 +11131,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz", - "integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -11945,9 +11460,9 @@ } }, "node_modules/react-day-picker": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz", - "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", "license": "MIT", "dependencies": { "@date-fns/tz": "^1.4.1", diff --git a/template/server/server.ts b/template/server/server.ts index da041927..e5f3b323 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,8 +1,7 @@ -import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; +import { createApp, {{.plugin_imports}} } from '@databricks/appkit'; createApp({ plugins: [ - server(), - {{.plugin_usage}}, + {{.plugin_usages}} ], }).catch(console.error);