diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 84ff2487..c0b8b18c 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -168,6 +168,22 @@ Omit.onSetupMessage *** +### postScaffold? + +```ts +optional postScaffold: PostScaffoldStep[]; +``` + +Ordered guidance for human or agent follow-up after scaffolding. + +#### Inherited from + +```ts +Omit.postScaffold +``` + +*** + ### repository? ```ts diff --git a/docs/docs/api/appkit/Interface.ResourceFieldEntry.md b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md index 324a82f0..91410c3d 100644 --- a/docs/docs/api/appkit/Interface.ResourceFieldEntry.md +++ b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md @@ -27,6 +27,16 @@ Human-readable description for this field *** +### discovery? + +```ts +optional discovery: DiscoveryDescriptor; +``` + +CLI discovery metadata for non-interactive resolution. + +*** + ### env? ```ts @@ -57,6 +67,16 @@ When true, this field is only generated for local .env files. The Databricks App *** +### resolution? + +```ts +optional resolution: ResourceResolution; +``` + +How this field's value is supplied to the app. + +*** + ### resolve? ```ts diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index ed4ef573..3e08d09a 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -91,6 +91,13 @@ "type": "string", "description": "Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning)." }, + "postScaffold": { + "type": "array", + "description": "Ordered guidance for human or agent follow-up after scaffolding.", + "items": { + "$ref": "#/$defs/postScaffoldStep" + } + }, "hidden": { "type": "boolean", "default": false, @@ -220,10 +227,53 @@ "type": "string", "pattern": "^[a-z_]+:[a-zA-Z]+$", "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow." + }, + "discovery": { + "$ref": "#/$defs/discoveryDescriptor" + }, + "resolution": { + "$ref": "#/$defs/resourceResolution" } }, "additionalProperties": false }, + "discoveryDescriptor": { + "type": "object", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "minLength": 1, + "description": "CLI command to list candidate values. Use as a placeholder for the Databricks CLI profile." + }, + "selectField": { + "type": "string", + "minLength": 1, + "description": "jq-style selector for the machine-readable value to capture from the CLI output." + }, + "displayField": { + "type": "string", + "minLength": 1, + "description": "jq-style selector for a human-readable label to display alongside the selected value." + }, + "dependsOn": { + "type": "string", + "minLength": 1, + "description": "Field name in the same resource that must be resolved before running this discovery command." + }, + "shortcut": { + "type": "string", + "minLength": 1, + "description": "Optional direct CLI command that returns a single preferred value." + } + }, + "additionalProperties": false + }, + "resourceResolution": { + "type": "string", + "enum": ["user-provided", "platform-injected"], + "description": "How this field's value is supplied to the app." + }, "resourceRequirement": { "type": "object", "description": "Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements().", @@ -405,6 +455,28 @@ } ] }, + "postScaffoldStep": { + "type": "object", + "required": ["step", "instruction"], + "properties": { + "step": { + "type": "integer", + "minimum": 1, + "description": "Step number in the ordered post-scaffold flow." + }, + "instruction": { + "type": "string", + "minLength": 1, + "description": "Instruction to follow after scaffolding completes." + }, + "blocking": { + "type": "boolean", + "default": false, + "description": "When true, this step must finish before continuing." + } + }, + "additionalProperties": false + }, "configSchemaProperty": { "type": "object", "required": ["type"], diff --git a/docs/static/schemas/template-plugins.schema.v2.json b/docs/static/schemas/template-plugins.schema.v2.json new file mode 100644 index 00000000..4c6e4432 --- /dev/null +++ b/docs/static/schemas/template-plugins.schema.v2.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + "title": "AppKit Template Plugins Manifest V2", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins, resource requirements, and scaffold guidance.", + "type": "object", + "required": ["version", "scaffolding", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "2.0", + "description": "Schema version for the template plugins manifest" + }, + "scaffolding": { + "$ref": "#/$defs/scaffolding" + }, + "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 or relative path that provides this plugin", + "examples": ["@databricks/appkit", "./plugins/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init." + }, + "onSetupMessage": { + "type": "string", + "description": "Human-facing message displayed after project initialization to explain manual setup steps." + }, + "postScaffold": { + "type": "array", + "description": "Ordered follow-up guidance for a human or agent after scaffolding.", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + } + }, + "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 + }, + "scaffoldingFlag": { + "type": "object", + "required": ["required", "description"], + "properties": { + "required": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "pattern": { + "type": "string", + "minLength": 1 + }, + "default": { + "type": "string" + } + }, + "additionalProperties": false + }, + "scaffolding": { + "type": "object", + "required": ["command", "flags", "rules"], + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "flags": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/scaffoldingFlag" + } + }, + "rules": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + "resourceType": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" + }, + "resourceFieldEntry": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + }, + "resourceRequirement": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" + } + } +} diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index 4a6a60c2..d0978fe6 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -14,7 +14,14 @@ "fields": { "id": { "env": "DATABRICKS_WAREHOUSE_ID", - "description": "SQL Warehouse ID" + "description": "SQL Warehouse ID", + "discovery": { + "cliCommand": "databricks warehouses list --profile -o json", + "selectField": ".id", + "displayField": ".name", + "shortcut": "databricks experimental aitools tools get-default-warehouse --profile " + }, + "resolution": "user-provided" } } } @@ -32,5 +39,29 @@ } } } - } + }, + "onSetupMessage": "Run 'databricks warehouses list' to find your SQL Warehouse ID.", + "postScaffold": [ + { + "step": 1, + "instruction": "Create SQL query files in config/queries/" + }, + { + "step": 2, + "instruction": "Run: npm run typegen", + "blocking": true + }, + { + "step": 3, + "instruction": "Read client/src/appKitTypes.d.ts for generated types" + }, + { + "step": 4, + "instruction": "Write UI code using the generated types" + }, + { + "step": 5, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] } diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json index c886deca..4d4b2915 100644 --- a/packages/appkit/src/plugins/files/manifest.json +++ b/packages/appkit/src/plugins/files/manifest.json @@ -14,7 +14,13 @@ "fields": { "path": { "env": "DATABRICKS_VOLUME_FILES", - "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)" + "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)", + "discovery": { + "cliCommand": "databricks volumes list --profile -o json", + "selectField": ".full_name", + "displayField": ".name" + }, + "resolution": "user-provided" } } } @@ -37,5 +43,16 @@ } } } - } + }, + "onSetupMessage": "Provide the full volume path, e.g. /Volumes/catalog/schema/volume_name.", + "postScaffold": [ + { + "step": 1, + "instruction": "Use the files plugin API to read/write volume files in your tRPC procedures" + }, + { + "step": 2, + "instruction": "Build UI for file upload/download using tRPC" + } + ] } diff --git a/packages/appkit/src/plugins/genie/manifest.json b/packages/appkit/src/plugins/genie/manifest.json index a269795d..3a4c0696 100644 --- a/packages/appkit/src/plugins/genie/manifest.json +++ b/packages/appkit/src/plugins/genie/manifest.json @@ -1,4 +1,5 @@ { + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", "name": "genie", "displayName": "Genie Plugin", "description": "AI/BI Genie space integration for natural language data queries", @@ -13,7 +14,13 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "discovery": { + "cliCommand": "databricks genie list-spaces --profile -o json", + "selectField": ".space_id", + "displayField": ".title" + }, + "resolution": "user-provided" } } } @@ -39,5 +46,24 @@ }, "required": ["spaces"] } - } + }, + "onSetupMessage": "Find your Genie Space ID in the AI/BI Genie UI.", + "postScaffold": [ + { + "step": 1, + "instruction": "Configure your Genie space aliases in server setup or rely on the default space ID" + }, + { + "step": 2, + "instruction": "Build a chat UI with GenieChat or useGenieChat against your chosen alias" + }, + { + "step": 3, + "instruction": "Wire your app routes and page navigation to the Genie experience" + }, + { + "step": 4, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] } diff --git a/packages/appkit/src/plugins/lakebase/manifest.json b/packages/appkit/src/plugins/lakebase/manifest.json index 2959c092..882b09a0 100644 --- a/packages/appkit/src/plugins/lakebase/manifest.json +++ b/packages/appkit/src/plugins/lakebase/manifest.json @@ -15,25 +15,38 @@ "fields": { "branch": { "description": "Full Lakebase Postgres branch resource name. Obtain by running `databricks postgres list-branches projects/{project-id}`, select the desired item from the output array and use its .name value.", - "examples": ["projects/{project-id}/branches/{branch-id}"] + "examples": ["projects/{project-id}/branches/{branch-id}"], + "discovery": { + "cliCommand": "databricks postgres list-branches projects/{project-id} --profile -o json", + "selectField": ".name" + }, + "resolution": "user-provided" }, "database": { "description": "Full Lakebase Postgres database resource name. Obtain by running `databricks postgres list-databases {branch-name}`, select the desired item from the output array and use its .name value. Requires the branch resource name.", "examples": [ "projects/{project-id}/branches/{branch-id}/databases/{database-id}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-databases {branch} --profile -o json", + "selectField": ".name", + "dependsOn": "branch" + }, + "resolution": "user-provided" }, "host": { "env": "PGHOST", "localOnly": true, "resolve": "postgres:host", - "description": "Postgres host for local development. Auto-injected by the platform at deploy time." + "description": "Postgres host for local development. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "databaseName": { "env": "PGDATABASE", "localOnly": true, "resolve": "postgres:databaseName", - "description": "Postgres database name for local development. Auto-injected by the platform at deploy time." + "description": "Postgres database name for local development. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "endpointPath": { "env": "LAKEBASE_ENDPOINT", @@ -42,23 +55,50 @@ "description": "Lakebase endpoint resource name. Auto-injected at runtime via app.yaml valueFrom: postgres. For local development, obtain by running `databricks postgres list-endpoints {branch-name}`, select the desired item from the output array and use its .name value.", "examples": [ "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-endpoints {branch} --profile -o json", + "selectField": ".name", + "dependsOn": "branch" + }, + "resolution": "user-provided" }, "port": { "env": "PGPORT", "localOnly": true, "value": "5432", - "description": "Postgres port. Auto-injected by the platform at deploy time." + "description": "Postgres port. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "sslmode": { "env": "PGSSLMODE", "localOnly": true, "value": "require", - "description": "Postgres SSL mode. Auto-injected by the platform at deploy time." + "description": "Postgres SSL mode. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" } } } ], "optional": [] - } + }, + "onSetupMessage": "Run 'databricks postgres list-branches' to find your Lakebase branch.", + "postScaffold": [ + { + "step": 1, + "instruction": "Define your schema in server/server.ts startup" + }, + { + "step": 2, + "instruction": "Write tRPC procedures using pool.query() in server/server.ts" + }, + { + "step": 3, + "instruction": "Build React frontend consuming tRPC procedures" + }, + { + "step": 4, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] } diff --git a/packages/appkit/src/registry/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts index f1bfc79a..59337ac5 100644 --- a/packages/appkit/src/registry/manifest-loader.ts +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -3,6 +3,7 @@ import { ConfigurationError } from "../errors"; import { createLogger } from "../logging/logger"; import type { PluginManifest, + ResourceFieldEntry, ResourcePermission, ResourceRequirement, } from "./types"; @@ -17,7 +18,7 @@ interface LooseResource { resourceKey: string; description: string; permission: string; - fields: Record; + fields: Record; } function normalizeType(s: string): ResourceType { diff --git a/packages/shared/src/cli/commands/plugin/list/list.test.ts b/packages/shared/src/cli/commands/plugin/list/list.test.ts index 2315362c..f979d34e 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.test.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -22,18 +22,30 @@ function cleanDir(dir: string): void { const TEMPLATE_MANIFEST_JSON = { $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + version: "2.0", + scaffolding: { + command: "databricks apps init", + flags: { + "--name": { + required: true, + description: "App name", + }, + }, + rules: ["Plugins with requiredByTemplate=true are included automatically."], + }, plugins: { server: { name: "server", displayName: "Server Plugin", + description: "HTTP server", package: "@databricks/appkit", resources: { required: [], optional: [] }, }, analytics: { name: "analytics", displayName: "Analytics Plugin", + description: "SQL query execution", package: "@databricks/appkit", resources: { required: [{ type: "sql_warehouse" }], @@ -93,8 +105,13 @@ describe("list", () => { manifestPath, JSON.stringify({ $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + version: "2.0", + scaffolding: { + command: "databricks apps init", + flags: {}, + rules: [], + }, plugins: {}, }), ); diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 1d896f49..416147f4 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -6,7 +6,10 @@ */ export type { + DiscoveryDescriptor, PluginManifest, + PostScaffoldStep, + ResourceResolution, ResourceFieldEntry, ResourceRequirement, } from "../../../schemas/plugin-manifest.generated"; @@ -19,8 +22,22 @@ export interface TemplatePlugin extends Omit { requiredByTemplate?: boolean; } +export interface ScaffoldingFlag { + required: boolean; + description: string; + pattern?: string; + default?: string; +} + +export interface Scaffolding { + command: string; + flags: Record; + rules: string[]; +} + export interface TemplatePluginsManifest { $schema: string; version: string; + scaffolding: Scaffolding; plugins: Record; } diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts index 64eec572..8d6c8711 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { Lang, parse } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; @@ -6,6 +8,10 @@ import { parseImports, parsePluginUsages, shouldAllowJsManifestForPackage, + TEMPLATE_PLUGINS_SCHEMA_ID, + TEMPLATE_PLUGINS_VERSION, + TEMPLATE_SCAFFOLDING, + writeManifest, } from "./sync"; describe("plugin sync", () => { @@ -182,4 +188,74 @@ describe("plugin sync", () => { expect(shouldAllowJsManifestForPackage("@acme/plugin")).toBe(false); }); }); + + describe("writeManifest", () => { + it("writes a v2 template manifest with scaffolding and plugin metadata", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-")); + const outputPath = path.join(tmpDir, "appkit.plugins.json"); + + writeManifest( + outputPath, + { + plugins: { + analytics: { + name: "analytics", + displayName: "Analytics Plugin", + description: + "SQL query execution against Databricks SQL Warehouses", + package: "@databricks/appkit", + onSetupMessage: + "Run 'databricks warehouses list' to find your SQL Warehouse ID.", + postScaffold: [ + { + step: 1, + instruction: "Create SQL query files in config/queries/", + }, + ], + 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", + discovery: { + cliCommand: + "databricks warehouses list --profile -o json", + selectField: ".id", + }, + resolution: "user-provided", + }, + }, + }, + ], + optional: [], + }, + }, + }, + }, + { write: true, silent: true }, + ); + + const manifest = JSON.parse( + fs.readFileSync(outputPath, "utf-8"), + ) as Record; + + expect(manifest.$schema).toBe(TEMPLATE_PLUGINS_SCHEMA_ID); + expect(manifest.version).toBe(TEMPLATE_PLUGINS_VERSION); + expect(manifest.scaffolding).toEqual(TEMPLATE_SCAFFOLDING); + expect( + (manifest.plugins as Record) + .analytics.postScaffold, + ).toEqual([ + { step: 1, instruction: "Create SQL query files in config/queries/" }, + ]); + }); + }); }); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..30f48acd 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -9,6 +9,7 @@ import { } from "../manifest-resolve"; import type { PluginManifest, + Scaffolding, TemplatePlugin, TemplatePluginsManifest, } from "../manifest-types"; @@ -57,6 +58,52 @@ function validateManifestWithSchema( /** Safety limit for recursive directory scanning to prevent runaway traversal. */ const MAX_SCAN_DEPTH = 5; +const TEMPLATE_PLUGINS_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json"; +const TEMPLATE_PLUGINS_VERSION = "2.0"; + +const TEMPLATE_SCAFFOLDING: Scaffolding = { + command: "databricks apps init", + flags: { + "--name": { + required: true, + description: + "App name: lowercase letters, numbers, hyphens only. Max 26 chars.", + pattern: "^[a-z][a-z0-9-]{0,25}$", + }, + "--features": { + required: false, + description: + "Comma-separated plugin names where requiredByTemplate is false. Do NOT include requiredByTemplate=true plugins; they are included automatically.", + }, + "--set": { + required: false, + description: + "Resource field values. Format: ..=. Required for every field in resources.required of all included plugins (both mandatory and optional), except fields marked resolution='platform-injected'.", + }, + "--profile": { + required: true, + description: "Databricks CLI profile for authentication.", + }, + "--description": { + required: false, + description: "Short description of the app.", + }, + "--run": { + required: false, + default: "none", + description: + "Post-scaffold action: none (review code first), dev (start dev server), or deploy.", + }, + }, + rules: [ + "Plugins with requiredByTemplate=true are included automatically. Do NOT add them to --features. You MUST still provide --set for their required resource fields unless those fields are platform-injected.", + "Plugins with requiredByTemplate=false are optional. Add to --features only when the user's request needs that capability.", + "Every field in a plugin's resources.required MUST have a --set flag for each included plugin (mandatory and optional), unless the field has resolution='platform-injected'.", + "Fields with resolution='platform-injected' are auto-set at deploy time. Do NOT include them in --set.", + ], +}; + /** * Load and validate a resolved manifest, returning a TemplatePlugin entry or null. * Centralises the resolve → load → validate → build-entry pipeline used by @@ -84,6 +131,9 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), }, ]; } @@ -413,6 +463,9 @@ async function scanForPlugins( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), } satisfies TemplatePlugin; } } @@ -523,9 +576,9 @@ function writeManifest( options: { write?: boolean; silent?: boolean }, ) { const templateManifest: TemplatePluginsManifest = { - $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + $schema: TEMPLATE_PLUGINS_SCHEMA_ID, + version: TEMPLATE_PLUGINS_VERSION, + scaffolding: TEMPLATE_SCAFFOLDING, plugins, }; @@ -767,6 +820,10 @@ export { parseImports, parsePluginUsages, shouldAllowJsManifestForPackage, + TEMPLATE_PLUGINS_SCHEMA_ID, + TEMPLATE_PLUGINS_VERSION, + TEMPLATE_SCAFFOLDING, + writeManifest, }; export const pluginsSyncCommand = new Command("sync") diff --git a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts index 63dd622d..a5dcd9dd 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts @@ -41,6 +41,28 @@ const VALID_MANIFEST_WITH_RESOURCE = { }, }; +const VALID_TEMPLATE_MANIFEST = { + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + version: "2.0", + scaffolding: { + command: "databricks apps init", + flags: { + "--name": { + required: true, + description: "App name", + pattern: "^[a-z][a-z0-9-]{0,25}$", + }, + "--profile": { + required: true, + description: "Databricks CLI profile for authentication.", + }, + }, + rules: ["Plugins with requiredByTemplate=true are included automatically."], + }, + plugins: {}, +}; + describe("validate-manifest", () => { describe("detectSchemaType", () => { it('returns "plugin-manifest" for plugin manifest $schema', () => { @@ -56,7 +78,7 @@ describe("validate-manifest", () => { expect( detectSchemaType({ $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", }), ).toBe("template-plugins"); }); @@ -94,6 +116,121 @@ describe("validate-manifest", () => { expect(result.manifest?.resources.required).toHaveLength(1); }); + it("rejects discovery CLI commands without --profile", () => { + const result = validateManifest({ + ...VALID_MANIFEST_WITH_RESOURCE, + resources: { + required: [ + { + ...VALID_MANIFEST_WITH_RESOURCE.resources.required[0], + fields: { + id: { + env: "DATABRICKS_WAREHOUSE_ID", + discovery: { + cliCommand: "databricks warehouses list -o json", + selectField: ".id", + }, + }, + }, + }, + ], + optional: [], + }, + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0]?.instancePath).toBe( + "/resources/required/0/fields/id/discovery/cliCommand", + ); + }); + + it("rejects discovery dependsOn values that reference missing fields", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "Required for persistence", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + database: { + discovery: { + cliCommand: + "databricks postgres list-databases {branch} --profile -o json", + selectField: ".name", + dependsOn: "branch", + }, + }, + }, + }, + ], + optional: [], + }, + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0]?.instancePath).toBe( + "/resources/required/0/fields/database/discovery/dependsOn", + ); + }); + + it("rejects cyclic discovery dependencies", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "Required for persistence", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + branch: { + discovery: { + cliCommand: + "databricks postgres list-branches projects/{project-id} --profile -o json", + selectField: ".name", + dependsOn: "database", + }, + }, + database: { + discovery: { + cliCommand: + "databricks postgres list-databases {branch} --profile -o json", + selectField: ".name", + dependsOn: "branch", + }, + }, + }, + }, + ], + optional: [], + }, + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0]?.instancePath).toBe( + "/resources/required/0/fields", + ); + }); + + it("rejects non-sequential postScaffold steps", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + postScaffold: [ + { step: 1, instruction: "Do one thing" }, + { step: 3, instruction: "Skip a number" }, + ], + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0]?.instancePath).toBe("/postScaffold"); + }); + it("rejects non-object input", () => { expect(validateManifest(null).valid).toBe(false); expect(validateManifest("string").valid).toBe(false); @@ -218,12 +355,7 @@ describe("validate-manifest", () => { describe("validateTemplateManifest", () => { it("validates a minimal correct template manifest", () => { - const result = validateTemplateManifest({ - $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", - plugins: {}, - }); + const result = validateTemplateManifest(VALID_TEMPLATE_MANIFEST); expect(result.valid).toBe(true); }); @@ -231,6 +363,47 @@ describe("validate-manifest", () => { expect(validateTemplateManifest(null).valid).toBe(false); expect(validateTemplateManifest("string").valid).toBe(false); }); + + it("rejects template plugin metadata with invalid discovery semantics", () => { + const result = validateTemplateManifest({ + ...VALID_TEMPLATE_MANIFEST, + 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: "Required for queries", + permission: "CAN_USE", + fields: { + id: { + env: "DATABRICKS_WAREHOUSE_ID", + discovery: { + cliCommand: "databricks warehouses list -o json", + selectField: ".id", + }, + }, + }, + }, + ], + optional: [], + }, + }, + }, + }); + + expect(result.valid).toBe(false); + expect(result.errors?.[0]?.instancePath).toBe( + "/plugins/analytics/resources/required/0/fields/id/discovery/cliCommand", + ); + }); }); describe("formatValidationErrors", () => { diff --git a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts index b0284b76..aafdc8e4 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import Ajv, { type ErrorObject } from "ajv"; import addFormats from "ajv-formats"; -import type { PluginManifest } from "../manifest-types"; +import type { + PluginManifest, + TemplatePluginsManifest, +} from "../manifest-types"; export type { PluginManifest }; @@ -13,9 +16,9 @@ const PLUGIN_MANIFEST_SCHEMA_PATH = path.join( SCHEMAS_DIR, "plugin-manifest.schema.json", ); -const TEMPLATE_PLUGINS_SCHEMA_PATH = path.join( +const TEMPLATE_PLUGINS_V2_SCHEMA_PATH = path.join( SCHEMAS_DIR, - "template-plugins.schema.json", + "template-plugins.schema.v2.json", ); export type SchemaType = "plugin-manifest" | "template-plugins" | "unknown"; @@ -23,7 +26,7 @@ export type SchemaType = "plugin-manifest" | "template-plugins" | "unknown"; const SCHEMA_ID_MAP: Record = { "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json": "plugin-manifest", - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json": + "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json": "template-plugins", }; @@ -44,6 +47,159 @@ export interface ValidateResult { errors?: ErrorObject[]; } +function makeSemanticError( + instancePath: string, + message: string, + params: Record = {}, +): ErrorObject { + return { + keyword: "semantic", + instancePath, + schemaPath: "#/semantic", + params, + message, + } as ErrorObject; +} + +function validateResourceFields( + fields: NonNullable< + PluginManifest["resources"]["required"] + >[number]["fields"], + instancePath: string, +): ErrorObject[] { + const errors: ErrorObject[] = []; + const fieldNames = new Set(Object.keys(fields)); + const dependencyGraph = new Map(); + + for (const [fieldName, field] of Object.entries(fields)) { + const discoveryPath = `${instancePath}/fields/${fieldName}/discovery`; + const fieldPath = `${instancePath}/fields/${fieldName}`; + + if ( + field.resolution !== undefined && + !["user-provided", "platform-injected"].includes(field.resolution) + ) { + errors.push( + makeSemanticError( + `${fieldPath}/resolution`, + 'must be "user-provided" or "platform-injected"', + ), + ); + } + + if (!field.discovery) continue; + + if (!field.discovery.cliCommand.includes("--profile")) { + errors.push( + makeSemanticError( + `${discoveryPath}/cliCommand`, + "must include --profile in discovery CLI commands", + ), + ); + } + + if ( + field.discovery.shortcut && + !field.discovery.shortcut.includes("--profile") + ) { + errors.push( + makeSemanticError( + `${discoveryPath}/shortcut`, + "must include --profile in discovery shortcut commands", + ), + ); + } + + if (field.discovery.dependsOn) { + if (!fieldNames.has(field.discovery.dependsOn)) { + errors.push( + makeSemanticError( + `${discoveryPath}/dependsOn`, + "must reference another field in the same resource", + { dependsOn: field.discovery.dependsOn }, + ), + ); + } else { + dependencyGraph.set(fieldName, field.discovery.dependsOn); + } + } + } + + const visited = new Set(); + const visiting = new Set(); + + function visit(fieldName: string) { + if (visiting.has(fieldName)) { + errors.push( + makeSemanticError( + `${instancePath}/fields`, + "discovery.dependsOn must not form a cycle", + ), + ); + return; + } + if (visited.has(fieldName)) return; + + visiting.add(fieldName); + const dependency = dependencyGraph.get(fieldName); + if (dependency) visit(dependency); + visiting.delete(fieldName); + visited.add(fieldName); + } + + for (const fieldName of dependencyGraph.keys()) { + visit(fieldName); + } + + return errors; +} + +function validatePostScaffold( + postScaffold: PluginManifest["postScaffold"], + instancePath: string, +): ErrorObject[] { + if (!postScaffold || postScaffold.length === 0) return []; + + const steps = postScaffold.map((step) => step.step); + const expected = Array.from( + { length: steps.length }, + (_, index) => index + 1, + ); + const isSequential = + steps.length === expected.length && + steps.every((step, index) => step === expected[index]); + + if (isSequential) return []; + + return [ + makeSemanticError( + `${instancePath}/postScaffold`, + "postScaffold steps must be unique, ordered, and sequential starting at 1", + { steps }, + ), + ]; +} + +function validatePluginSemantics( + plugin: Pick, + instancePath: string, +): ErrorObject[] { + const errors: ErrorObject[] = []; + const required = plugin.resources?.required ?? []; + const optional = plugin.resources?.optional ?? []; + + for (const [index, resource] of [...required, ...optional].entries()) { + const resourcePath = + index < required.length + ? `${instancePath}/resources/required/${index}` + : `${instancePath}/resources/optional/${index - required.length}`; + errors.push(...validateResourceFields(resource.fields, resourcePath)); + } + + errors.push(...validatePostScaffold(plugin.postScaffold, instancePath)); + return errors; +} + let schemaLoadWarned = false; function loadSchema(schemaPath: string): object | null { @@ -81,7 +237,7 @@ let compiledTemplateValidator: ReturnType | null = null; function getTemplateValidator(): ReturnType | null { if (compiledTemplateValidator) return compiledTemplateValidator; const pluginSchema = loadSchema(PLUGIN_MANIFEST_SCHEMA_PATH); - const templateSchema = loadSchema(TEMPLATE_PLUGINS_SCHEMA_PATH); + const templateSchema = loadSchema(TEMPLATE_PLUGINS_V2_SCHEMA_PATH); if (!pluginSchema || !templateSchema) return null; try { const ajv = new Ajv({ allErrors: true, strict: false }); @@ -137,7 +293,13 @@ export function validateManifest(obj: unknown): ValidateResult { } const valid = validate(obj); - if (valid) return { valid: true, manifest: obj as PluginManifest }; + if (valid) { + const semanticErrors = validatePluginSemantics(obj as PluginManifest, ""); + if (semanticErrors.length > 0) { + return { valid: false, errors: semanticErrors }; + } + return { valid: true, manifest: obj as PluginManifest }; + } return { valid: false, errors: validate.errors ?? [] }; } @@ -178,7 +340,17 @@ export function validateTemplateManifest(obj: unknown): ValidateResult { } const valid = validate(obj); - if (valid) return { valid: true }; + if (valid) { + const manifest = obj as TemplatePluginsManifest; + const semanticErrors = Object.entries(manifest.plugins).flatMap( + ([pluginName, plugin]) => + validatePluginSemantics(plugin, `/plugins/${pluginName}`), + ); + if (semanticErrors.length > 0) { + return { valid: false, errors: semanticErrors }; + } + return { valid: true }; + } return { valid: false, errors: validate.errors ?? [] }; } diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 761bdce6..89a0bac1 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,13 +1,21 @@ import type express from "express"; import type { JSONSchema7 } from "json-schema"; import type { + DiscoveryDescriptor, PluginManifest as GeneratedPluginManifest, + PostScaffoldStep, ResourceRequirement as GeneratedResourceRequirement, + ResourceResolution, ResourceFieldEntry, } from "./schemas/plugin-manifest.generated"; // Re-export generated types as the shared canonical definitions. -export type { ResourceFieldEntry }; +export type { + DiscoveryDescriptor, + PostScaffoldStep, + ResourceFieldEntry, + ResourceResolution, +}; /** Base plugin interface. */ export interface BasePlugin { diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts index 5d2e5d4a..cbc6f8cc 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -147,6 +147,27 @@ export type ExperimentPermission = "CAN_READ" | "CAN_EDIT" | "CAN_MANAGE"; */ export type AppPermission = "CAN_USE"; +/** + * Ordered guidance for human or agent follow-up after scaffolding. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "postScaffoldStep". + */ +export interface PostScaffoldStep { + /** + * Step number in the ordered post-scaffold flow. + */ + step: number; + /** + * Instruction to follow after scaffolding completes. + */ + instruction: string; + /** + * When true, this step must finish before continuing. + */ + blocking?: boolean; +} + /** * Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options. */ @@ -210,11 +231,50 @@ export interface PluginManifest { * Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning). */ onSetupMessage?: string; + /** + * Ordered guidance for human or agent follow-up after scaffolding. + */ + postScaffold?: PostScaffoldStep[]; /** * When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync. */ hidden?: boolean; } +/** + * CLI discovery metadata for non-interactive resolution. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "discoveryDescriptor". + */ +export interface DiscoveryDescriptor { + /** + * CLI command to list candidate values. Use as a placeholder for the Databricks CLI profile. + */ + cliCommand: string; + /** + * jq-style selector for the machine-readable value to capture from the CLI output. + */ + selectField: string; + /** + * jq-style selector for a human-readable label to display alongside the selected value. + */ + displayField?: string; + /** + * Field name in the same resource that must be resolved before running this discovery command. + */ + dependsOn?: string; + /** + * Optional direct CLI command that returns a single preferred value. + */ + shortcut?: string; +} +/** + * How this field's value is supplied to the app. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "resourceResolution". + */ +export type ResourceResolution = "user-provided" | "platform-injected"; /** * 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). * @@ -250,6 +310,14 @@ export interface ResourceFieldEntry { * Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow. */ resolve?: string; + /** + * CLI discovery metadata for non-interactive resolution. + */ + discovery?: DiscoveryDescriptor; + /** + * How this field's value is supplied to the app. + */ + resolution?: ResourceResolution; } /** * This interface was referenced by `PluginManifest`'s JSON-Schema diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index ed4ef573..3e08d09a 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -91,6 +91,13 @@ "type": "string", "description": "Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning)." }, + "postScaffold": { + "type": "array", + "description": "Ordered guidance for human or agent follow-up after scaffolding.", + "items": { + "$ref": "#/$defs/postScaffoldStep" + } + }, "hidden": { "type": "boolean", "default": false, @@ -220,10 +227,53 @@ "type": "string", "pattern": "^[a-z_]+:[a-zA-Z]+$", "description": "Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow." + }, + "discovery": { + "$ref": "#/$defs/discoveryDescriptor" + }, + "resolution": { + "$ref": "#/$defs/resourceResolution" } }, "additionalProperties": false }, + "discoveryDescriptor": { + "type": "object", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "minLength": 1, + "description": "CLI command to list candidate values. Use as a placeholder for the Databricks CLI profile." + }, + "selectField": { + "type": "string", + "minLength": 1, + "description": "jq-style selector for the machine-readable value to capture from the CLI output." + }, + "displayField": { + "type": "string", + "minLength": 1, + "description": "jq-style selector for a human-readable label to display alongside the selected value." + }, + "dependsOn": { + "type": "string", + "minLength": 1, + "description": "Field name in the same resource that must be resolved before running this discovery command." + }, + "shortcut": { + "type": "string", + "minLength": 1, + "description": "Optional direct CLI command that returns a single preferred value." + } + }, + "additionalProperties": false + }, + "resourceResolution": { + "type": "string", + "enum": ["user-provided", "platform-injected"], + "description": "How this field's value is supplied to the app." + }, "resourceRequirement": { "type": "object", "description": "Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements().", @@ -405,6 +455,28 @@ } ] }, + "postScaffoldStep": { + "type": "object", + "required": ["step", "instruction"], + "properties": { + "step": { + "type": "integer", + "minimum": 1, + "description": "Step number in the ordered post-scaffold flow." + }, + "instruction": { + "type": "string", + "minLength": 1, + "description": "Instruction to follow after scaffolding completes." + }, + "blocking": { + "type": "boolean", + "default": false, + "description": "When true, this step must finish before continuing." + } + }, + "additionalProperties": false + }, "configSchemaProperty": { "type": "object", "required": ["type"], diff --git a/packages/shared/src/schemas/template-plugins.schema.v2.json b/packages/shared/src/schemas/template-plugins.schema.v2.json new file mode 100644 index 00000000..4c6e4432 --- /dev/null +++ b/packages/shared/src/schemas/template-plugins.schema.v2.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + "title": "AppKit Template Plugins Manifest V2", + "description": "Aggregated plugin manifest for AppKit templates. Read by Databricks CLI during init to discover available plugins, resource requirements, and scaffold guidance.", + "type": "object", + "required": ["version", "scaffolding", "plugins"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON Schema for validation" + }, + "version": { + "type": "string", + "const": "2.0", + "description": "Schema version for the template plugins manifest" + }, + "scaffolding": { + "$ref": "#/$defs/scaffolding" + }, + "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 or relative path that provides this plugin", + "examples": ["@databricks/appkit", "./plugins/custom-plugin"] + }, + "requiredByTemplate": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is required by the template and cannot be deselected during CLI init." + }, + "onSetupMessage": { + "type": "string", + "description": "Human-facing message displayed after project initialization to explain manual setup steps." + }, + "postScaffold": { + "type": "array", + "description": "Ordered follow-up guidance for a human or agent after scaffolding.", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + } + }, + "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 + }, + "scaffoldingFlag": { + "type": "object", + "required": ["required", "description"], + "properties": { + "required": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "pattern": { + "type": "string", + "minLength": 1 + }, + "default": { + "type": "string" + } + }, + "additionalProperties": false + }, + "scaffolding": { + "type": "object", + "required": ["command", "flags", "rules"], + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "flags": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/scaffoldingFlag" + } + }, + "rules": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + "resourceType": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" + }, + "resourceFieldEntry": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + }, + "resourceRequirement": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" + } + } +} diff --git a/packages/shared/tsdown.config.ts b/packages/shared/tsdown.config.ts index d118f7ab..3b756290 100644 --- a/packages/shared/tsdown.config.ts +++ b/packages/shared/tsdown.config.ts @@ -30,5 +30,9 @@ export default defineConfig({ from: "src/schemas/template-plugins.schema.json", to: "dist/schemas", }, + { + from: "src/schemas/template-plugins.schema.v2.json", + to: "dist/schemas", + }, ], }); diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index cf60a8af..19415ff2 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -1,6 +1,43 @@ { - "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - "version": "1.0", + "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.v2.json", + "version": "2.0", + "scaffolding": { + "command": "databricks apps init", + "flags": { + "--name": { + "required": true, + "description": "App name: lowercase letters, numbers, hyphens only. Max 26 chars.", + "pattern": "^[a-z][a-z0-9-]{0,25}$" + }, + "--features": { + "required": false, + "description": "Comma-separated plugin names where requiredByTemplate is false. Do NOT include requiredByTemplate=true plugins; they are included automatically." + }, + "--set": { + "required": false, + "description": "Resource field values. Format: ..=. Required for every field in resources.required of all included plugins (both mandatory and optional), except fields marked resolution='platform-injected'." + }, + "--profile": { + "required": true, + "description": "Databricks CLI profile for authentication." + }, + "--description": { + "required": false, + "description": "Short description of the app." + }, + "--run": { + "required": false, + "default": "none", + "description": "Post-scaffold action: none (review code first), dev (start dev server), or deploy." + } + }, + "rules": [ + "Plugins with requiredByTemplate=true are included automatically. Do NOT add them to --features. You MUST still provide --set for their required resource fields unless those fields are platform-injected.", + "Plugins with requiredByTemplate=false are optional. Add to --features only when the user's request needs that capability.", + "Every field in a plugin's resources.required MUST have a --set flag for each included plugin (mandatory and optional), unless the field has resolution='platform-injected'.", + "Fields with resolution='platform-injected' are auto-set at deploy time. Do NOT include them in --set." + ] + }, "plugins": { "analytics": { "name": "analytics", @@ -18,13 +55,44 @@ "fields": { "id": { "env": "DATABRICKS_WAREHOUSE_ID", - "description": "SQL Warehouse ID" + "description": "SQL Warehouse ID", + "discovery": { + "cliCommand": "databricks warehouses list --profile -o json", + "selectField": ".id", + "displayField": ".name", + "shortcut": "databricks experimental aitools tools get-default-warehouse --profile " + }, + "resolution": "user-provided" } } } ], "optional": [] - } + }, + "onSetupMessage": "Run 'databricks warehouses list' to find your SQL Warehouse ID.", + "postScaffold": [ + { + "step": 1, + "instruction": "Create SQL query files in config/queries/" + }, + { + "step": 2, + "instruction": "Run: npm run typegen", + "blocking": true + }, + { + "step": 3, + "instruction": "Read client/src/appKitTypes.d.ts for generated types" + }, + { + "step": 4, + "instruction": "Write UI code using the generated types" + }, + { + "step": 5, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] }, "files": { "name": "files", @@ -42,13 +110,30 @@ "fields": { "path": { "env": "DATABRICKS_VOLUME_FILES", - "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)" + "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)", + "discovery": { + "cliCommand": "databricks volumes list --profile -o json", + "selectField": ".full_name", + "displayField": ".name" + }, + "resolution": "user-provided" } } } ], "optional": [] - } + }, + "onSetupMessage": "Provide the full volume path, e.g. /Volumes/catalog/schema/volume_name.", + "postScaffold": [ + { + "step": 1, + "instruction": "Use the files plugin API to read/write volume files in your tRPC procedures" + }, + { + "step": 2, + "instruction": "Build UI for file upload/download using tRPC" + } + ] }, "genie": { "name": "genie", @@ -66,13 +151,38 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "discovery": { + "cliCommand": "databricks genie list-spaces --profile -o json", + "selectField": ".space_id", + "displayField": ".title" + }, + "resolution": "user-provided" } } } ], "optional": [] - } + }, + "onSetupMessage": "Find your Genie Space ID in the AI/BI Genie UI.", + "postScaffold": [ + { + "step": 1, + "instruction": "Configure your Genie space aliases in server setup or rely on the default space ID" + }, + { + "step": 2, + "instruction": "Build a chat UI with GenieChat or useGenieChat against your chosen alias" + }, + { + "step": 3, + "instruction": "Wire your app routes and page navigation to the Genie experience" + }, + { + "step": 4, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] }, "lakebase": { "name": "lakebase", @@ -92,25 +202,38 @@ "description": "Full Lakebase Postgres branch resource name. Obtain by running `databricks postgres list-branches projects/{project-id}`, select the desired item from the output array and use its .name value.", "examples": [ "projects/{project-id}/branches/{branch-id}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-branches projects/{project-id} --profile -o json", + "selectField": ".name" + }, + "resolution": "user-provided" }, "database": { "description": "Full Lakebase Postgres database resource name. Obtain by running `databricks postgres list-databases {branch-name}`, select the desired item from the output array and use its .name value. Requires the branch resource name.", "examples": [ "projects/{project-id}/branches/{branch-id}/databases/{database-id}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-databases {branch} --profile -o json", + "selectField": ".name", + "dependsOn": "branch" + }, + "resolution": "user-provided" }, "host": { "env": "PGHOST", "localOnly": true, "resolve": "postgres:host", - "description": "Postgres host for local development. Auto-injected by the platform at deploy time." + "description": "Postgres host for local development. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "databaseName": { "env": "PGDATABASE", "localOnly": true, "resolve": "postgres:databaseName", - "description": "Postgres database name for local development. Auto-injected by the platform at deploy time." + "description": "Postgres database name for local development. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "endpointPath": { "env": "LAKEBASE_ENDPOINT", @@ -119,25 +242,52 @@ "description": "Lakebase endpoint resource name. Auto-injected at runtime via app.yaml valueFrom: postgres. For local development, obtain by running `databricks postgres list-endpoints {branch-name}`, select the desired item from the output array and use its .name value.", "examples": [ "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-endpoints {branch} --profile -o json", + "selectField": ".name", + "dependsOn": "branch" + }, + "resolution": "user-provided" }, "port": { "env": "PGPORT", "localOnly": true, "value": "5432", - "description": "Postgres port. Auto-injected by the platform at deploy time." + "description": "Postgres port. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" }, "sslmode": { "env": "PGSSLMODE", "localOnly": true, "value": "require", - "description": "Postgres SSL mode. Auto-injected by the platform at deploy time." + "description": "Postgres SSL mode. Auto-injected by the platform at deploy time.", + "resolution": "platform-injected" } } } ], "optional": [] - } + }, + "onSetupMessage": "Run 'databricks postgres list-branches' to find your Lakebase branch.", + "postScaffold": [ + { + "step": 1, + "instruction": "Define your schema in server/server.ts startup" + }, + { + "step": 2, + "instruction": "Write tRPC procedures using pool.query() in server/server.ts" + }, + { + "step": 3, + "instruction": "Build React frontend consuming tRPC procedures" + }, + { + "step": 4, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] }, "server": { "name": "server",