From 85bac4b5f56fc268cb33e33f447d2beecb508b8b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 16 Mar 2026 18:48:08 +0100 Subject: [PATCH] chore: poc manifest 1 --- .../api/appkit/Interface.PluginManifest.md | 16 ++ .../appkit/Interface.ResourceFieldEntry.md | 18 ++ .../schemas/plugin-manifest.schema.json | 64 ++++++ .../schemas/template-plugins.schema.json | 62 +++++- .../src/plugins/analytics/manifest.json | 22 +- .../appkit/src/plugins/files/manifest.json | 15 +- .../appkit/src/plugins/genie/manifest.json | 17 +- .../appkit/src/plugins/lakebase/manifest.json | 35 ++- packages/appkit/src/registry/types.ts | 2 +- .../src/cli/commands/plugin/manifest-types.ts | 16 ++ .../src/cli/commands/plugin/sync/sync.test.ts | 120 ++++++++++ .../src/cli/commands/plugin/sync/sync.ts | 92 +++++++- .../plugin/validate/validate-manifest.test.ts | 207 ++++++++++++++++++ packages/shared/src/plugin.ts | 4 +- .../src/schemas/plugin-manifest.generated.ts | 57 +++++ .../src/schemas/plugin-manifest.schema.json | 64 ++++++ .../src/schemas/template-plugins.schema.json | 62 +++++- template/appkit.plugins.json | 167 ++++++++++++-- 18 files changed, 1011 insertions(+), 29 deletions(-) diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 84ff2487..30fca01f 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 steps to follow after scaffolding. Steps marked blocking must complete before proceeding. + +#### 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..28cac67d 100644 --- a/docs/docs/api/appkit/Interface.ResourceFieldEntry.md +++ b/docs/docs/api/appkit/Interface.ResourceFieldEntry.md @@ -27,6 +27,14 @@ Human-readable description for this field *** +### discovery? + +```ts +optional discovery: DiscoveryDescriptor; +``` + +*** + ### env? ```ts @@ -67,6 +75,16 @@ Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolv *** +### setupHint? + +```ts +optional setupHint: string; +``` + +Short actionable instruction for obtaining this field's value. + +*** + ### value? ```ts diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index ed4ef573..c7ac6194 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -95,6 +95,13 @@ "type": "boolean", "default": false, "description": "When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync." + }, + "postScaffold": { + "type": "array", + "description": "Ordered steps to follow after scaffolding. Steps marked blocking must complete before proceeding.", + "items": { + "$ref": "#/$defs/postScaffoldStep" + } } }, "additionalProperties": false, @@ -220,6 +227,13 @@ "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" + }, + "setupHint": { + "type": "string", + "description": "Short actionable instruction for obtaining this field's value." } }, "additionalProperties": false @@ -405,6 +419,56 @@ } ] }, + "discoveryDescriptor": { + "type": "object", + "description": "How to discover candidate values for this field via CLI commands.", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "description": "CLI command to list candidate values. Use as a placeholder for the Databricks CLI profile." + }, + "selectField": { + "type": "string", + "description": "jq-style field to extract the value from each result (e.g. '.id')." + }, + "displayField": { + "type": "string", + "description": "jq-style field for human-readable display (e.g. '.name')." + }, + "dependsOn": { + "type": "string", + "description": "Field name in the same resource that must be resolved first." + }, + "shortcut": { + "type": "string", + "description": "Optional command that returns a single value directly instead of a list." + } + }, + "additionalProperties": false + }, + "postScaffoldStep": { + "type": "object", + "description": "A single post-scaffolding step.", + "required": ["step", "instruction"], + "properties": { + "step": { + "type": "integer", + "minimum": 1, + "description": "Step number (sequential, starting from 1)." + }, + "instruction": { + "type": "string", + "description": "What to do in this step." + }, + "blocking": { + "type": "boolean", + "default": false, + "description": "When true, this step must finish before proceeding to the next." + } + }, + "additionalProperties": false + }, "configSchemaProperty": { "type": "object", "required": ["type"], diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 290edd05..5b233ffa 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -12,9 +12,12 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "1.1"], "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", @@ -25,6 +28,38 @@ }, "additionalProperties": false, "$defs": { + "scaffolding": { + "type": "object", + "description": "Template-level metadata for constructing the scaffolding CLI command.", + "required": ["command", "flags", "rules"], + "properties": { + "command": { + "type": "string", + "description": "The base CLI command for scaffolding." + }, + "flags": { + "type": "object", + "description": "Map of flag name to flag descriptor.", + "additionalProperties": { + "type": "object", + "required": ["required", "description"], + "properties": { + "required": { "type": "boolean" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "default": { "type": "string" } + }, + "additionalProperties": false + } + }, + "rules": { + "type": "array", + "items": { "type": "string" }, + "description": "Rules for constructing the scaffolding command." + } + }, + "additionalProperties": false + }, "templatePlugin": { "type": "object", "required": [ @@ -69,6 +104,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 steps to follow after scaffolding.", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + } + }, "resources": { "type": "object", "required": ["required", "optional"], @@ -98,7 +140,23 @@ "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + "allOf": [ + { "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, + { + "properties": { + "resolution": { + "type": "string", + "enum": [ + "user-provided", + "platform-injected", + "static", + "cli-resolved" + ], + "description": "Computed during sync. Indicates how the field's value is provided: user-provided (needs --set), platform-injected (auto-set at deploy), static (has a default value), or cli-resolved (resolved by the CLI during init)." + } + } + } + ] }, "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..d0e5c6f0 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -14,13 +14,33 @@ "fields": { "id": { "env": "DATABRICKS_WAREHOUSE_ID", - "description": "SQL Warehouse ID" + "description": "SQL Warehouse ID", + "setupHint": "Run 'databricks warehouses list' to find your SQL Warehouse ID.", + "discovery": { + "cliCommand": "databricks warehouses list --profile -o json", + "selectField": ".id", + "displayField": ".name", + "shortcut": "databricks experimental aitools tools get-default-warehouse --profile " + } } } } ], "optional": [] }, + "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" + } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json index c886deca..b45dc904 100644 --- a/packages/appkit/src/plugins/files/manifest.json +++ b/packages/appkit/src/plugins/files/manifest.json @@ -14,13 +14,26 @@ "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)", + "setupHint": "Provide the full volume path, e.g. /Volumes/catalog/schema/volume_name.", + "discovery": { + "cliCommand": "databricks volumes list . --profile -o json", + "selectField": ".full_name", + "displayField": ".name" + } } } } ], "optional": [] }, + "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" } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/genie/manifest.json b/packages/appkit/src/plugins/genie/manifest.json index a269795d..bae3704d 100644 --- a/packages/appkit/src/plugins/genie/manifest.json +++ b/packages/appkit/src/plugins/genie/manifest.json @@ -13,13 +13,28 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "setupHint": "Find your Genie Space ID in the AI/BI Genie UI." } } } ], "optional": [] }, + "postScaffold": [ + { + "step": 1, + "instruction": "Configure Genie Space aliases in the plugin config spaces map" + }, + { + "step": 2, + "instruction": "Build UI components to send natural language queries via the Genie tRPC procedures" + }, + { + "step": 3, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/lakebase/manifest.json b/packages/appkit/src/plugins/lakebase/manifest.json index 2959c092..16821478 100644 --- a/packages/appkit/src/plugins/lakebase/manifest.json +++ b/packages/appkit/src/plugins/lakebase/manifest.json @@ -15,13 +15,24 @@ "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}"], + "setupHint": "Run 'databricks postgres list-branches projects/{project-id}' to find your branch.", + "discovery": { + "cliCommand": "databricks postgres list-branches projects/{project-id} --profile -o json", + "selectField": ".name" + } }, "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}" - ] + ], + "setupHint": "Run 'databricks postgres list-databases {branch-name}' to find your database. Requires the branch first.", + "discovery": { + "cliCommand": "databricks postgres list-databases {branch} --profile -o json", + "selectField": ".name", + "dependsOn": "branch" + } }, "host": { "env": "PGHOST", @@ -60,5 +71,23 @@ } ], "optional": [] - } + }, + "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/types.ts b/packages/appkit/src/registry/types.ts index b26227df..7a1324c7 100644 --- a/packages/appkit/src/registry/types.ts +++ b/packages/appkit/src/registry/types.ts @@ -29,7 +29,7 @@ export { type ResourcePermission, }; -// Re-export generated base type from shared (schema-derived, used directly). +// Re-export generated base types from shared (schema-derived, used directly). export type { ResourceFieldEntry } from "shared"; // Import shared base types for strict extension below. diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 1d896f49..aca42196 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -6,7 +6,9 @@ */ export type { + DiscoveryDescriptor, PluginManifest, + PostScaffoldStep, ResourceFieldEntry, ResourceRequirement, } from "../../../schemas/plugin-manifest.generated"; @@ -19,8 +21,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..9660df6d 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts @@ -1,7 +1,10 @@ import path from "node:path"; import { Lang, parse } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; +import type { TemplatePluginsManifest } from "../manifest-types"; import { + computeResolution, + enrichPluginsWithResolution, isWithinDirectory, parseImports, parsePluginUsages, @@ -182,4 +185,121 @@ describe("plugin sync", () => { expect(shouldAllowJsManifestForPackage("@acme/plugin")).toBe(false); }); }); + + describe("computeResolution", () => { + it('returns "platform-injected" when localOnly is true', () => { + expect(computeResolution({ localOnly: true })).toBe("platform-injected"); + }); + + it('returns "platform-injected" when localOnly is true even with value and resolve', () => { + expect( + computeResolution({ + localOnly: true, + value: "5432", + resolve: "postgres:host", + }), + ).toBe("platform-injected"); + }); + + it('returns "static" when value is set', () => { + expect(computeResolution({ value: "5432" })).toBe("static"); + }); + + it('returns "static" for non-empty value without localOnly', () => { + expect(computeResolution({ value: "require" })).toBe("static"); + }); + + it('returns "cli-resolved" when resolve is set', () => { + expect(computeResolution({ resolve: "postgres:host" })).toBe( + "cli-resolved", + ); + }); + + it('returns "user-provided" when no localOnly, value, or resolve', () => { + expect(computeResolution({})).toBe("user-provided"); + expect( + computeResolution({ env: "MY_VAR", description: "Some field" }), + ).toBe("user-provided"); + }); + + it('returns "user-provided" for empty value string', () => { + expect(computeResolution({ value: "" })).toBe("user-provided"); + }); + }); + + describe("enrichPluginsWithResolution", () => { + it("injects resolution on every resource field", () => { + const plugins: TemplatePluginsManifest["plugins"] = { + analytics: { + name: "analytics", + displayName: "Analytics", + description: "Test", + package: "@databricks/appkit", + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "test", + permission: "CAN_USE", + fields: { + id: { env: "WAREHOUSE_ID", description: "Warehouse ID" }, + }, + }, + ], + optional: [], + }, + }, + lakebase: { + name: "lakebase", + displayName: "Lakebase", + description: "Test", + package: "@databricks/appkit", + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "test", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + branch: { description: "Branch" }, + host: { + env: "PGHOST", + localOnly: true, + resolve: "postgres:host", + }, + port: { env: "PGPORT", localOnly: true, value: "5432" }, + endpoint: { resolve: "postgres:endpointPath" }, + }, + }, + ], + optional: [], + }, + }, + }; + + enrichPluginsWithResolution(plugins); + + const analyticsId = plugins.analytics.resources.required[0].fields + .id as Record; + expect(analyticsId.resolution).toBe("user-provided"); + + const lakebaseFields = plugins.lakebase.resources.required[0].fields; + expect( + (lakebaseFields.branch as Record).resolution, + ).toBe("user-provided"); + expect((lakebaseFields.host as Record).resolution).toBe( + "platform-injected", + ); + expect((lakebaseFields.port as Record).resolution).toBe( + "platform-injected", + ); + expect( + (lakebaseFields.endpoint as Record).resolution, + ).toBe("cli-resolved"); + }); + }); }); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..7fb18f0c 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -9,6 +9,8 @@ import { } from "../manifest-resolve"; import type { PluginManifest, + ResourceFieldEntry, + Scaffolding, TemplatePlugin, TemplatePluginsManifest, } from "../manifest-types"; @@ -84,6 +86,9 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), }, ]; } @@ -413,6 +418,9 @@ async function scanForPlugins( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), } satisfies TemplatePlugin; } } @@ -514,6 +522,81 @@ async function scanPluginsDir( return plugins; } +/** + * Derive the resolution strategy for a resource field from its existing properties. + */ +function computeResolution( + field: ResourceFieldEntry, +): "platform-injected" | "static" | "cli-resolved" | "user-provided" { + if (field.localOnly) return "platform-injected"; + if (field.value !== undefined && field.value !== "") return "static"; + if (field.resolve) return "cli-resolved"; + return "user-provided"; +} + +/** + * Walk every resource field in every plugin and inject the computed `resolution` value. + * This is emitted only in the template manifest — not authored in per-plugin manifests. + */ +function enrichPluginsWithResolution( + plugins: TemplatePluginsManifest["plugins"], +): void { + for (const plugin of Object.values(plugins)) { + for (const resource of [ + ...plugin.resources.required, + ...plugin.resources.optional, + ]) { + if (!resource.fields) continue; + for (const field of Object.values(resource.fields)) { + (field as ResourceFieldEntry & { resolution: string }).resolution = + computeResolution(field); + } + } + } +} + +const 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 with resolution='user-provided' in all included plugins (both mandatory and optional).", + }, + "--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 user-provided resource fields.", + "Plugins with requiredByTemplate=false are optional. Add to --features only when the user's request needs that capability.", + "Every field with resolution='user-provided' MUST have a --set value for each included plugin (mandatory + optional).", + "Fields with resolution='platform-injected', 'static', or 'cli-resolved' are handled automatically — do NOT include them in --set.", + ], +}; + /** * Write (or preview) the template plugins manifest to disk. */ @@ -522,10 +605,13 @@ function writeManifest( { plugins }: { plugins: TemplatePluginsManifest["plugins"] }, options: { write?: boolean; silent?: boolean }, ) { + enrichPluginsWithResolution(plugins); + const templateManifest: TemplatePluginsManifest = { $schema: "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + version: "1.1", + scaffolding: SCAFFOLDING, plugins, }; @@ -761,8 +847,10 @@ async function runPluginsSync(options: { writeManifest(outputPath, { plugins }, options); } -/** Exported for testing: path boundary check, AST parsing, trust checks. */ +/** Exported for testing: path boundary check, AST parsing, trust checks, resolution. */ export { + computeResolution, + enrichPluginsWithResolution, isWithinDirectory, parseImports, parsePluginUsages, 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..ba95e71d 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 @@ -216,6 +216,187 @@ describe("validate-manifest", () => { }); }); + describe("validateManifest – new fields", () => { + it("accepts manifest with discovery on a resource field", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "Required for queries", + permission: "CAN_USE", + fields: { + id: { + env: "DATABRICKS_WAREHOUSE_ID", + description: "SQL Warehouse ID", + setupHint: "Run 'databricks warehouses list' to find it.", + discovery: { + cliCommand: + "databricks warehouses list --profile -o json", + selectField: ".id", + displayField: ".name", + }, + }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(true); + }); + + it("accepts manifest with discovery including dependsOn and shortcut", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "Database", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + branch: { + description: "Branch", + discovery: { + cliCommand: + "databricks postgres list-branches --profile -o json", + selectField: ".name", + }, + }, + database: { + description: "Database", + discovery: { + cliCommand: + "databricks postgres list-databases {branch} --profile -o json", + selectField: ".name", + dependsOn: "branch", + }, + }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(true); + }); + + it("rejects discovery missing required cliCommand", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "test", + permission: "CAN_USE", + fields: { + id: { + env: "WAREHOUSE_ID", + discovery: { + selectField: ".id", + }, + }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(false); + }); + + it("rejects discovery with unknown property", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "test", + permission: "CAN_USE", + fields: { + id: { + env: "WAREHOUSE_ID", + discovery: { + cliCommand: "databricks warehouses list -o json", + selectField: ".id", + unknownField: "bad", + }, + }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(false); + }); + + it("accepts manifest with postScaffold steps", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + postScaffold: [ + { step: 1, instruction: "Create files" }, + { step: 2, instruction: "Run typegen", blocking: true }, + { step: 3, instruction: "Write UI code" }, + ], + }); + expect(result.valid).toBe(true); + }); + + it("rejects postScaffold step missing instruction", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + postScaffold: [{ step: 1 }], + }); + expect(result.valid).toBe(false); + }); + + it("rejects postScaffold step with invalid step number", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + postScaffold: [{ step: 0, instruction: "Invalid step" }], + }); + expect(result.valid).toBe(false); + }); + + it("accepts manifest with setupHint on a field", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "volume", + alias: "Files", + resourceKey: "files", + description: "Volume access", + permission: "WRITE_VOLUME", + fields: { + path: { + env: "VOLUME_PATH", + setupHint: "Provide the full volume path.", + }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(true); + }); + }); + describe("validateTemplateManifest", () => { it("validates a minimal correct template manifest", () => { const result = validateTemplateManifest({ @@ -227,6 +408,32 @@ describe("validate-manifest", () => { expect(result.valid).toBe(true); }); + it("validates a v1.1 template manifest with scaffolding", () => { + const result = validateTemplateManifest({ + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.1", + scaffolding: { + command: "databricks apps init", + flags: { + "--name": { + required: true, + description: "App name", + pattern: "^[a-z][a-z0-9-]{0,25}$", + }, + "--run": { + required: false, + description: "Post-scaffold action", + default: "none", + }, + }, + rules: ["Rule 1", "Rule 2"], + }, + plugins: {}, + }); + expect(result.valid).toBe(true); + }); + it("rejects non-object input", () => { expect(validateTemplateManifest(null).valid).toBe(false); expect(validateTemplateManifest("string").valid).toBe(false); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 761bdce6..8a1b678b 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -1,13 +1,15 @@ import type express from "express"; import type { JSONSchema7 } from "json-schema"; import type { + DiscoveryDescriptor, PluginManifest as GeneratedPluginManifest, ResourceRequirement as GeneratedResourceRequirement, + PostScaffoldStep, ResourceFieldEntry, } from "./schemas/plugin-manifest.generated"; // Re-export generated types as the shared canonical definitions. -export type { ResourceFieldEntry }; +export type { DiscoveryDescriptor, PostScaffoldStep, ResourceFieldEntry }; /** 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..5f5768bd 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -214,6 +214,10 @@ export interface PluginManifest { * When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync. */ hidden?: boolean; + /** + * Ordered steps to follow after scaffolding. Steps marked blocking must complete before proceeding. + */ + postScaffold?: PostScaffoldStep[]; } /** * 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 +254,39 @@ 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; + discovery?: DiscoveryDescriptor; + /** + * Short actionable instruction for obtaining this field's value. + */ + setupHint?: string; +} +/** + * How to discover candidate values for this field via CLI commands. + * + * 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 field to extract the value from each result (e.g. '.id'). + */ + selectField: string; + /** + * jq-style field for human-readable display (e.g. '.name'). + */ + displayField?: string; + /** + * Field name in the same resource that must be resolved first. + */ + dependsOn?: string; + /** + * Optional command that returns a single value directly instead of a list. + */ + shortcut?: string; } /** * This interface was referenced by `PluginManifest`'s JSON-Schema @@ -283,3 +320,23 @@ export interface ConfigSchemaProperty { maxLength?: number; required?: string[]; } +/** + * A single post-scaffolding step. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "postScaffoldStep". + */ +export interface PostScaffoldStep { + /** + * Step number (sequential, starting from 1). + */ + step: number; + /** + * What to do in this step. + */ + instruction: string; + /** + * When true, this step must finish before proceeding to the next. + */ + blocking?: boolean; +} diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index ed4ef573..c7ac6194 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -95,6 +95,13 @@ "type": "boolean", "default": false, "description": "When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync." + }, + "postScaffold": { + "type": "array", + "description": "Ordered steps to follow after scaffolding. Steps marked blocking must complete before proceeding.", + "items": { + "$ref": "#/$defs/postScaffoldStep" + } } }, "additionalProperties": false, @@ -220,6 +227,13 @@ "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" + }, + "setupHint": { + "type": "string", + "description": "Short actionable instruction for obtaining this field's value." } }, "additionalProperties": false @@ -405,6 +419,56 @@ } ] }, + "discoveryDescriptor": { + "type": "object", + "description": "How to discover candidate values for this field via CLI commands.", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "description": "CLI command to list candidate values. Use as a placeholder for the Databricks CLI profile." + }, + "selectField": { + "type": "string", + "description": "jq-style field to extract the value from each result (e.g. '.id')." + }, + "displayField": { + "type": "string", + "description": "jq-style field for human-readable display (e.g. '.name')." + }, + "dependsOn": { + "type": "string", + "description": "Field name in the same resource that must be resolved first." + }, + "shortcut": { + "type": "string", + "description": "Optional command that returns a single value directly instead of a list." + } + }, + "additionalProperties": false + }, + "postScaffoldStep": { + "type": "object", + "description": "A single post-scaffolding step.", + "required": ["step", "instruction"], + "properties": { + "step": { + "type": "integer", + "minimum": 1, + "description": "Step number (sequential, starting from 1)." + }, + "instruction": { + "type": "string", + "description": "What to do in this step." + }, + "blocking": { + "type": "boolean", + "default": false, + "description": "When true, this step must finish before proceeding to the next." + } + }, + "additionalProperties": false + }, "configSchemaProperty": { "type": "object", "required": ["type"], diff --git a/packages/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json index 290edd05..5b233ffa 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -12,9 +12,12 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "1.1"], "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", @@ -25,6 +28,38 @@ }, "additionalProperties": false, "$defs": { + "scaffolding": { + "type": "object", + "description": "Template-level metadata for constructing the scaffolding CLI command.", + "required": ["command", "flags", "rules"], + "properties": { + "command": { + "type": "string", + "description": "The base CLI command for scaffolding." + }, + "flags": { + "type": "object", + "description": "Map of flag name to flag descriptor.", + "additionalProperties": { + "type": "object", + "required": ["required", "description"], + "properties": { + "required": { "type": "boolean" }, + "description": { "type": "string" }, + "pattern": { "type": "string" }, + "default": { "type": "string" } + }, + "additionalProperties": false + } + }, + "rules": { + "type": "array", + "items": { "type": "string" }, + "description": "Rules for constructing the scaffolding command." + } + }, + "additionalProperties": false + }, "templatePlugin": { "type": "object", "required": [ @@ -69,6 +104,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 steps to follow after scaffolding.", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + } + }, "resources": { "type": "object", "required": ["required", "optional"], @@ -98,7 +140,23 @@ "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + "allOf": [ + { "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, + { + "properties": { + "resolution": { + "type": "string", + "enum": [ + "user-provided", + "platform-injected", + "static", + "cli-resolved" + ], + "description": "Computed during sync. Indicates how the field's value is provided: user-provided (needs --set), platform-injected (auto-set at deploy), static (has a default value), or cli-resolved (resolved by the CLI during init)." + } + } + } + ] }, "resourceRequirement": { "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index cf60a8af..baf70ca2 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", + "version": "1.1", + "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 with resolution='user-provided' in all included plugins (both mandatory and optional)." + }, + "--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 user-provided resource fields.", + "Plugins with requiredByTemplate=false are optional. Add to --features only when the user's request needs that capability.", + "Every field with resolution='user-provided' MUST have a --set value for each included plugin (mandatory + optional).", + "Fields with resolution='platform-injected', 'static', or 'cli-resolved' are handled automatically — 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", + "setupHint": "Run 'databricks warehouses list' to find your 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": [] - } + }, + "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)", + "setupHint": "Provide the full volume path, e.g. /Volumes/catalog/schema/volume_name.", + "discovery": { + "cliCommand": "databricks volumes list . --profile -o json", + "selectField": ".full_name", + "displayField": ".name" + }, + "resolution": "user-provided" } } } ], "optional": [] - } + }, + "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,29 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "setupHint": "Find your Genie Space ID in the AI/BI Genie UI.", + "resolution": "user-provided" } } } ], "optional": [] - } + }, + "postScaffold": [ + { + "step": 1, + "instruction": "Configure Genie Space aliases in the plugin config spaces map" + }, + { + "step": 2, + "instruction": "Build UI components to send natural language queries via the Genie tRPC procedures" + }, + { + "step": 3, + "instruction": "Update tests/smoke.spec.ts selectors for your app" + } + ] }, "lakebase": { "name": "lakebase", @@ -92,25 +193,40 @@ "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}" - ] + ], + "setupHint": "Run 'databricks postgres list-branches projects/{project-id}' to find your branch.", + "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}" - ] + ], + "setupHint": "Run 'databricks postgres list-databases {branch-name}' to find your database. Requires the branch first.", + "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 +235,46 @@ "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}" - ] + ], + "resolution": "cli-resolved" }, "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": [] - } + }, + "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",