From 4c42eddb9e9557489d5aba28bdf9a3c7bb39ba6e Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 8 Apr 2026 17:49:09 +0200 Subject: [PATCH 1/7] feat: extend plugin and template manifest schemas with discovery, postScaffold, and scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xavier loop: iteration 1 — Phase 1 (Schema Definitions & Type Generation) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/cli/commands/plugin/manifest-types.ts | 30 ++++- packages/shared/src/plugin.ts | 4 +- .../src/schemas/plugin-manifest.generated.ts | 74 ++++++++++++ .../src/schemas/plugin-manifest.schema.json | 65 +++++++++++ .../src/schemas/template-plugins.schema.json | 105 +++++++++++++++++- 5 files changed, 274 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 1d896f49..10b9e947 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -6,21 +6,49 @@ */ export type { + DiscoveryDescriptor, PluginManifest, + PostScaffoldStep, ResourceFieldEntry, ResourceRequirement, } from "../../../schemas/plugin-manifest.generated"; -import type { PluginManifest } from "../../../schemas/plugin-manifest.generated"; +import type { + PluginManifest, + PostScaffoldStep, +} from "../../../schemas/plugin-manifest.generated"; + +export interface ScaffoldingFlag { + description: string; + required?: boolean; + pattern?: string; + default?: string; +} + +export interface ScaffoldingRules { + never?: string[]; + must?: string[]; +} + +export interface ScaffoldingDescriptor { + command: string; + flags?: Record; + rules?: ScaffoldingRules; +} + +export type Origin = "user" | "platform" | "static" | "cli"; export interface TemplatePlugin extends Omit { package: string; /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ requiredByTemplate?: boolean; + /** Ordered list of post-scaffolding instructions propagated from the plugin manifest. */ + postScaffold?: PostScaffoldStep[]; } export interface TemplatePluginsManifest { $schema: string; version: string; plugins: Record; + scaffolding?: ScaffoldingDescriptor; } diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 9fa8066c..38dc8427 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 { ResourceFieldEntry, DiscoveryDescriptor, PostScaffoldStep }; /** 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..683e9d64 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 list of post-scaffolding instructions shown to the user after project initialization. Array position determines display order. + */ + 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,32 @@ 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; +} +/** + * How the CLI discovers values for this field via a Databricks CLI command. + */ +export interface DiscoveryDescriptor { + /** + * Databricks CLI command that lists resources. Must include placeholder. + */ + cliCommand: string; + /** + * jq-style path to the field used as the selected value (e.g., '.id', '.name'). + */ + selectField: string; + /** + * jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted. + */ + displayField?: string; + /** + * Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields. + */ + dependsOn?: string; + /** + * Single-value fast-path command that returns exactly one value, skipping interactive selection. + */ + shortcut?: string; } /** * This interface was referenced by `PluginManifest`'s JSON-Schema @@ -283,3 +313,47 @@ export interface ConfigSchemaProperty { maxLength?: number; required?: string[]; } +/** + * A post-scaffolding instruction shown to the user after project initialization. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "postScaffoldStep". + */ +export interface PostScaffoldStep { + /** + * Human-readable instruction for the user to follow after scaffolding. + */ + instruction: string; + /** + * Whether this step is required for the plugin to function correctly. + */ + required?: boolean; +} +/** + * Describes how the CLI discovers values for a resource field via a Databricks CLI command. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "discoveryDescriptor". + */ +export interface DiscoveryDescriptor1 { + /** + * Databricks CLI command that lists resources. Must include placeholder. + */ + cliCommand: string; + /** + * jq-style path to the field used as the selected value (e.g., '.id', '.name'). + */ + selectField: string; + /** + * jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted. + */ + displayField?: string; + /** + * Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields. + */ + dependsOn?: string; + /** + * Single-value fast-path command that returns exactly one value, skipping interactive selection. + */ + shortcut?: string; +} diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index ed4ef573..5d99a93d 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", + "items": { + "$ref": "#/$defs/postScaffoldStep" + }, + "description": "Ordered list of post-scaffolding instructions shown to the user after project initialization. Array position determines display order." } }, "additionalProperties": false, @@ -220,6 +227,10 @@ "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", + "description": "How the CLI discovers values for this field via a Databricks CLI command." } }, "additionalProperties": false @@ -478,6 +489,60 @@ "type": "boolean" } } + }, + "discoveryDescriptor": { + "type": "object", + "description": "Describes how the CLI discovers values for a resource field via a Databricks CLI command.", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include placeholder.", + "examples": [ + "databricks warehouses list --profile --output json" + ] + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name').", + "examples": [".id", ".name", ".catalog_name"] + }, + "displayField": { + "type": "string", + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "examples": [".name", ".display_name"] + }, + "dependsOn": { + "type": "string", + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "examples": ["branch", "catalog"] + }, + "shortcut": { + "type": "string", + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "examples": [ + "databricks warehouses get --profile --output json" + ] + } + }, + "additionalProperties": false + }, + "postScaffoldStep": { + "type": "object", + "description": "A post-scaffolding instruction shown to the user after project initialization.", + "required": ["instruction"], + "properties": { + "instruction": { + "type": "string", + "description": "Human-readable instruction for the user to follow after scaffolding." + }, + "required": { + "type": "boolean", + "default": true, + "description": "Whether this step is required for the plugin to function correctly." + } + }, + "additionalProperties": false } } } diff --git a/packages/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json index 290edd05..94f377a0 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -12,7 +12,7 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "2.0"], "description": "Schema version for the template plugins manifest" }, "plugins": { @@ -21,9 +21,24 @@ "additionalProperties": { "$ref": "#/$defs/templatePlugin" } + }, + "scaffolding": { + "$ref": "#/$defs/scaffoldingDescriptor", + "description": "Describes the scaffolding command and its configuration for project initialization." } }, "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "version": { "const": "2.0" } }, + "required": ["version"] + }, + "then": { + "required": ["version", "plugins", "scaffolding"] + } + } + ], "$defs": { "templatePlugin": { "type": "object", @@ -69,6 +84,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", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + }, + "description": "Ordered list of post-scaffolding instructions propagated from the plugin manifest." + }, "resources": { "type": "object", "required": ["required", "optional"], @@ -98,10 +120,89 @@ "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + "allOf": [ + { "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, + { + "properties": { + "origin": { + "$ref": "#/$defs/origin" + } + } + } + ] }, "resourceRequirement": { "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" + }, + "origin": { + "type": "string", + "enum": ["user", "platform", "static", "cli"], + "description": "How the field value is determined. Computed during sync, not authored by plugin developers." + }, + "scaffoldingFlag": { + "type": "object", + "description": "A flag for the scaffolding command.", + "required": ["description"], + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of the flag." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this flag is required." + }, + "pattern": { + "type": "string", + "description": "Regex pattern for validating the flag value." + }, + "default": { + "type": "string", + "description": "Default value for this flag." + } + }, + "additionalProperties": false + }, + "scaffoldingRules": { + "type": "object", + "description": "Structured rules for scaffolding agents.", + "properties": { + "never": { + "type": "array", + "items": { "type": "string" }, + "description": "Actions the scaffolding agent must never perform." + }, + "must": { + "type": "array", + "items": { "type": "string" }, + "description": "Actions the scaffolding agent must always perform." + } + }, + "additionalProperties": false + }, + "scaffoldingDescriptor": { + "type": "object", + "description": "Describes the scaffolding command, flags, and rules for project initialization.", + "required": ["command"], + "properties": { + "command": { + "type": "string", + "description": "The scaffolding command (e.g., 'databricks apps init')." + }, + "flags": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/scaffoldingFlag" + }, + "description": "Map of flag name to flag descriptor." + }, + "rules": { + "$ref": "#/$defs/scaffoldingRules", + "description": "Structured rules for scaffolding agents." + } + }, + "additionalProperties": false } } } From 7fe3d7424a46b582fbc28884c80b86e0277661bb Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 8 Apr 2026 17:54:15 +0200 Subject: [PATCH 2/7] feat: add origin computation, scaffolding descriptor, and v2.0 template manifest emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xavier loop: iteration 2 — Phase 2 (Origin Computation & Sync Enrichment) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/cli/commands/plugin/sync/sync.test.ts | 45 ++++++++++ .../src/cli/commands/plugin/sync/sync.ts | 89 ++++++++++++++++++- template/appkit.plugins.json | 61 ++++++++++--- 3 files changed, 182 insertions(+), 13 deletions(-) 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..af380536 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { Lang, parse } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; import { + computeOrigin, isWithinDirectory, parseImports, parsePluginUsages, @@ -182,4 +183,48 @@ describe("plugin sync", () => { expect(shouldAllowJsManifestForPackage("@acme/plugin")).toBe(false); }); }); + + describe("computeOrigin", () => { + it("returns 'platform' when localOnly is true", () => { + expect(computeOrigin({ env: "PGHOST", localOnly: true })).toBe( + "platform", + ); + }); + + it("returns 'platform' when localOnly is true even with resolve", () => { + expect( + computeOrigin({ + env: "PGHOST", + localOnly: true, + resolve: "postgres:host", + }), + ).toBe("platform"); + }); + + it("returns 'static' when value is present", () => { + expect(computeOrigin({ env: "PGPORT", value: "5432" })).toBe("static"); + }); + + it("returns 'cli' when resolve is present", () => { + expect( + computeOrigin({ + env: "LAKEBASE_ENDPOINT", + resolve: "postgres:endpointPath", + }), + ).toBe("cli"); + }); + + it("returns 'user' for fields with no special properties", () => { + expect( + computeOrigin({ + env: "DATABRICKS_WAREHOUSE_ID", + description: "Warehouse ID", + }), + ).toBe("user"); + }); + + it("returns 'user' for minimal field with only env", () => { + expect(computeOrigin({ env: "MY_VAR" })).toBe("user"); + }); + }); }); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..d860b7ac 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -8,7 +8,10 @@ import { resolveManifestInDir, } from "../manifest-resolve"; import type { + Origin, PluginManifest, + ResourceFieldEntry, + ScaffoldingDescriptor, TemplatePlugin, TemplatePluginsManifest, } from "../manifest-types"; @@ -36,6 +39,43 @@ function isWithinDirectory(filePath: string, boundary: string): boolean { ); } +/** + * Derives the origin of a resource field value based on its properties. + * - localOnly: true → "platform" (auto-injected by Databricks Apps platform) + * - value present → "static" (hardcoded value) + * - resolve present → "cli" (resolved by CLI during init) + * - else → "user" (user must provide the value) + */ +function computeOrigin(field: ResourceFieldEntry): Origin { + if (field.localOnly) return "platform"; + if (field.value !== undefined) return "static"; + if (field.resolve !== undefined) return "cli"; + return "user"; +} + +/** + * Injects computed `origin` onto every resource field in all plugins. + * Mutates the plugins object in place for efficiency. + */ +function enrichFieldsWithOrigin( + plugins: TemplatePluginsManifest["plugins"], +): void { + for (const plugin of Object.values(plugins)) { + for (const group of [ + plugin.resources.required, + plugin.resources.optional, + ]) { + for (const resource of group) { + if (!resource.fields) continue; + for (const field of Object.values(resource.fields)) { + (field as ResourceFieldEntry & { origin?: Origin }).origin = + computeOrigin(field); + } + } + } + } +} + /** * Validates a parsed JSON object against the plugin-manifest JSON schema. * Returns the manifest if valid, or null and logs schema errors. @@ -84,6 +124,9 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), }, ]; } @@ -107,6 +150,40 @@ const SERVER_FILE_CANDIDATES = ["server/server.ts", "server/index.ts"]; */ const CONVENTIONAL_LOCAL_PLUGIN_DIRS = ["plugins", "server"]; +/** + * Scaffolding descriptor for the `databricks apps init` command. + * Included in v2.0 template manifests to guide scaffolding agents. + */ +const TEMPLATE_SCAFFOLDING: ScaffoldingDescriptor = { + command: "databricks apps init", + flags: { + "--template-dir": { + description: "Path to the template directory containing the app scaffold", + required: true, + }, + "--config-dir": { + description: "Path to the output directory for the initialized app", + required: true, + }, + "--profile": { + description: "Databricks CLI profile to use for authentication", + required: false, + }, + }, + rules: { + never: [ + "Modify files inside the template directory", + "Skip resource configuration prompts", + "Hardcode workspace-specific values in template files", + ], + must: [ + "Use the template manifest (appkit.plugins.json) as the source of truth for available plugins", + "Respect requiredByTemplate flags when presenting plugin selection", + "Generate .env files with all required environment variables from selected plugins", + ], + }, +}; + /** * Find the server entry file by checking candidate paths in order. * @@ -413,6 +490,9 @@ async function scanForPlugins( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.postScaffold && { + postScaffold: manifest.postScaffold, + }), } satisfies TemplatePlugin; } } @@ -522,11 +602,15 @@ function writeManifest( { plugins }: { plugins: TemplatePluginsManifest["plugins"] }, options: { write?: boolean; silent?: boolean }, ) { + // Enrich fields with computed origin for v2.0 + enrichFieldsWithOrigin(plugins); + const templateManifest: TemplatePluginsManifest = { $schema: "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + version: "2.0", plugins, + scaffolding: TEMPLATE_SCAFFOLDING, }; if (options.write) { @@ -761,8 +845,9 @@ 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, origin computation. */ export { + computeOrigin, isWithinDirectory, parseImports, parsePluginUsages, diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index cf60a8af..394e2db1 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -1,6 +1,6 @@ { "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - "version": "1.0", + "version": "2.0", "plugins": { "analytics": { "name": "analytics", @@ -18,7 +18,8 @@ "fields": { "id": { "env": "DATABRICKS_WAREHOUSE_ID", - "description": "SQL Warehouse ID" + "description": "SQL Warehouse ID", + "origin": "user" } } } @@ -42,7 +43,8 @@ "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)", + "origin": "user" } } } @@ -66,7 +68,8 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "origin": "user" } } } @@ -92,25 +95,29 @@ "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}" - ] + ], + "origin": "user" }, "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}" - ] + ], + "origin": "user" }, "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.", + "origin": "platform" }, "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.", + "origin": "platform" }, "endpointPath": { "env": "LAKEBASE_ENDPOINT", @@ -119,19 +126,22 @@ "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}" - ] + ], + "origin": "cli" }, "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.", + "origin": "platform" }, "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.", + "origin": "platform" } } } @@ -150,5 +160,34 @@ }, "requiredByTemplate": true } + }, + "scaffolding": { + "command": "databricks apps init", + "flags": { + "--template-dir": { + "description": "Path to the template directory containing the app scaffold", + "required": true + }, + "--config-dir": { + "description": "Path to the output directory for the initialized app", + "required": true + }, + "--profile": { + "description": "Databricks CLI profile to use for authentication", + "required": false + } + }, + "rules": { + "never": [ + "Modify files inside the template directory", + "Skip resource configuration prompts", + "Hardcode workspace-specific values in template files" + ], + "must": [ + "Use the template manifest (appkit.plugins.json) as the source of truth for available plugins", + "Respect requiredByTemplate flags when presenting plugin selection", + "Generate .env files with all required environment variables from selected plugins" + ] + } } } From b90516725b64bf0743f08fbdd3513e2f33cde6c8 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 8 Apr 2026 18:01:12 +0200 Subject: [PATCH 3/7] feat: add semantic validation for dependsOn cycles, discovery profile, and postScaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xavier loop: iteration 3 — Phase 3 (Semantic Validation) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../plugin/validate/validate-manifest.test.ts | 215 ++++++++++++++++++ .../plugin/validate/validate-manifest.ts | 193 ++++++++++++++++ .../cli/commands/plugin/validate/validate.ts | 56 ++++- 3 files changed, 459 insertions(+), 5 deletions(-) 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..2999858b 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 @@ -1,8 +1,11 @@ import type { ErrorObject } from "ajv"; import { describe, expect, it } from "vitest"; +import type { PluginManifest } from "./validate-manifest"; import { detectSchemaType, + formatSemanticIssues, formatValidationErrors, + runSemanticValidation, validateManifest, validateTemplateManifest, } from "./validate-manifest"; @@ -388,4 +391,216 @@ describe("validate-manifest", () => { expect(output).toContain('missing required property "name"'); }); }); + + describe("semantic validation", () => { + it("returns no issues for a valid manifest without discovery", () => { + const result = runSemanticValidation( + VALID_MANIFEST_WITH_RESOURCE as PluginManifest, + ); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("detects dangling dependsOn reference", () => { + const manifest = { + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "test", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + branch: { + env: "BRANCH", + description: "Branch name", + discovery: { + cliCommand: + "databricks postgres list-branches --profile ", + selectField: ".name", + dependsOn: "nonexistent", + }, + }, + }, + }, + ], + optional: [], + }, + }; + const result = runSemanticValidation( + manifest as unknown as PluginManifest, + ); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain("non-existent sibling field"); + expect(result.errors[0].message).toContain("nonexistent"); + }); + + it("detects cyclic dependsOn chain", () => { + const manifest = { + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "test", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + a: { + env: "A", + discovery: { + cliCommand: "databricks cmd --profile ", + selectField: ".id", + dependsOn: "b", + }, + }, + b: { + env: "B", + discovery: { + cliCommand: "databricks cmd --profile ", + selectField: ".id", + dependsOn: "a", + }, + }, + }, + }, + ], + optional: [], + }, + }; + const result = runSemanticValidation( + manifest as unknown as PluginManifest, + ); + const cycleErrors = result.errors.filter((e) => + e.message.includes("cycle"), + ); + expect(cycleErrors.length).toBeGreaterThan(0); + }); + + it("detects missing in cliCommand", () => { + const manifest = { + ...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 --output json", + selectField: ".id", + }, + }, + }, + }, + ], + optional: [], + }, + }; + const result = runSemanticValidation( + manifest as unknown as PluginManifest, + ); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain(""); + }); + + it("warns when discovery is on non-user origin field", () => { + const manifest = { + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "postgres", + alias: "Postgres", + resourceKey: "postgres", + description: "test", + permission: "CAN_CONNECT_AND_CREATE", + fields: { + host: { + env: "PGHOST", + localOnly: true, + discovery: { + cliCommand: "databricks cmd --profile ", + selectField: ".host", + }, + }, + }, + }, + ], + optional: [], + }, + }; + const result = runSemanticValidation( + manifest as unknown as PluginManifest, + ); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].message).toContain("platform"); + }); + + it("passes for valid manifest with all new fields", () => { + const manifest = { + ...VALID_MANIFEST, + postScaffold: [ + { instruction: "Run migrations", required: true }, + { instruction: "Verify connectivity" }, + ], + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "test", + permission: "CAN_USE", + fields: { + id: { + env: "WAREHOUSE_ID", + description: "Warehouse ID", + discovery: { + cliCommand: + "databricks warehouses list --profile --output json", + selectField: ".id", + displayField: ".name", + }, + }, + }, + }, + ], + optional: [], + }, + }; + const result = runSemanticValidation( + manifest as unknown as PluginManifest, + ); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("formats semantic issues correctly", () => { + const issues = [ + { + level: "error" as const, + path: "resources.postgres.fields.host", + message: "test error", + }, + { + level: "warning" as const, + path: "postScaffold[0]", + message: "test warning", + }, + ]; + const output = formatSemanticIssues(issues); + expect(output).toContain("resources.postgres.fields.host: test error"); + expect(output).toContain("postScaffold[0]: test warning"); + }); + }); }); 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..35d7ffd2 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import Ajv, { type ErrorObject } from "ajv"; import addFormats from "ajv-formats"; import type { PluginManifest } from "../manifest-types"; +import { computeOrigin } from "../sync/sync"; export type { PluginManifest }; @@ -303,3 +304,195 @@ export function formatValidationErrors( return lines.join("\n"); } + +// ── Semantic validation (cross-field / cross-resource rules) ──────────── + +export interface SemanticIssue { + level: "error" | "warning"; + path: string; + message: string; +} + +export interface SemanticValidateResult { + errors: SemanticIssue[]; + warnings: SemanticIssue[]; +} + +function validateDependsOn(manifest: PluginManifest): SemanticIssue[] { + const issues: SemanticIssue[] = []; + + for (const group of [ + manifest.resources.required, + manifest.resources.optional, + ]) { + for (const resource of group) { + if (!resource.fields) continue; + const fieldNames = new Set(Object.keys(resource.fields)); + + // Check dangling references + const deps = new Map(); + for (const [name, field] of Object.entries(resource.fields)) { + const discovery = (field as Record).discovery as + | { dependsOn?: string } + | undefined; + if (discovery?.dependsOn) { + if (!fieldNames.has(discovery.dependsOn)) { + issues.push({ + level: "error", + path: `resources.${resource.resourceKey}.fields.${name}.discovery.dependsOn`, + message: `references non-existent sibling field '${discovery.dependsOn}'`, + }); + } + deps.set(name, discovery.dependsOn); + } + } + + // Detect cycles via DFS + const visited = new Set(); + const visiting = new Set(); + + function dfs(node: string, chain: string[]): string[] | null { + if (visiting.has(node)) return [...chain, node]; + if (visited.has(node)) return null; + visiting.add(node); + const next = deps.get(node); + if (next) { + const cycle = dfs(next, [...chain, node]); + if (cycle) return cycle; + } + visiting.delete(node); + visited.add(node); + return null; + } + + for (const node of deps.keys()) { + if (!visited.has(node)) { + const cycle = dfs(node, []); + if (cycle) { + issues.push({ + level: "error", + path: `resources.${resource.resourceKey}`, + message: `discovery.dependsOn creates a cycle: ${cycle.join(" \u2192 ")}`, + }); + break; // one cycle error per resource is enough + } + } + } + } + } + + return issues; +} + +function validateDiscoveryProfile(manifest: PluginManifest): SemanticIssue[] { + const issues: SemanticIssue[] = []; + + for (const group of [ + manifest.resources.required, + manifest.resources.optional, + ]) { + for (const resource of group) { + if (!resource.fields) continue; + for (const [name, field] of Object.entries(resource.fields)) { + const discovery = (field as Record).discovery as + | { cliCommand?: string } + | undefined; + if ( + discovery?.cliCommand && + !discovery.cliCommand.includes("") + ) { + issues.push({ + level: "error", + path: `resources.${resource.resourceKey}.fields.${name}.discovery.cliCommand`, + message: "must include placeholder", + }); + } + } + } + } + + return issues; +} + +function validateDiscoveryOrigin(manifest: PluginManifest): SemanticIssue[] { + const issues: SemanticIssue[] = []; + + for (const group of [ + manifest.resources.required, + manifest.resources.optional, + ]) { + for (const resource of group) { + if (!resource.fields) continue; + for (const [name, field] of Object.entries(resource.fields)) { + const discovery = (field as Record).discovery; + if (!discovery) continue; + const origin = computeOrigin(field); + if (origin !== "user") { + issues.push({ + level: "warning", + path: `resources.${resource.resourceKey}.fields.${name}`, + message: `has discovery but computed origin is '${origin}' (not 'user') \u2014 discovery may not be used`, + }); + } + } + } + } + + return issues; +} + +function validatePostScaffold(manifest: PluginManifest): SemanticIssue[] { + const issues: SemanticIssue[] = []; + const { postScaffold } = manifest; + if (!postScaffold) return issues; + + if (!Array.isArray(postScaffold)) { + issues.push({ + level: "error", + path: "postScaffold", + message: "must be an array", + }); + return issues; + } + + for (let i = 0; i < postScaffold.length; i++) { + const step = postScaffold[i]; + if (!step || typeof step !== "object") { + issues.push({ + level: "error", + path: `postScaffold[${i}]`, + message: "must be an object", + }); + continue; + } + if (typeof step.instruction !== "string" || step.instruction.length === 0) { + issues.push({ + level: "error", + path: `postScaffold[${i}].instruction`, + message: "must be a non-empty string", + }); + } + } + + return issues; +} + +export function runSemanticValidation( + manifest: PluginManifest, +): SemanticValidateResult { + const allIssues = [ + ...validateDependsOn(manifest), + ...validateDiscoveryProfile(manifest), + ...validateDiscoveryOrigin(manifest), + ...validatePostScaffold(manifest), + ]; + + return { + errors: allIssues.filter((i) => i.level === "error"), + warnings: allIssues.filter((i) => i.level === "warning"), + }; +} + +export function formatSemanticIssues(issues: SemanticIssue[]): string { + return issues.map((i) => ` ${i.path}: ${i.message}`).join("\n"); +} diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts index 76ccfbae..085d9cf2 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -7,9 +7,16 @@ import { type ResolvedManifest, resolveManifestInDir, } from "../manifest-resolve"; +import type { + PluginManifest, + SemanticValidateResult, + ValidateResult, +} from "./validate-manifest"; import { detectSchemaType, + formatSemanticIssues, formatValidationErrors, + runSemanticValidation, validateManifest, validateTemplateManifest, } from "./validate-manifest"; @@ -95,14 +102,31 @@ async function runPluginValidate( } const schemaType = detectSchemaType(obj); - const result = - schemaType === "template-plugins" - ? validateTemplateManifest(obj) - : validateManifest(obj); + let result: ValidateResult; + let semanticResult: SemanticValidateResult | undefined; + + if (schemaType === "template-plugins") { + result = validateTemplateManifest(obj); + } else { + result = validateManifest(obj); + if (result.valid && result.manifest) { + semanticResult = runSemanticValidation(result.manifest); + } + } const relativePath = path.relative(cwd, manifestPath); if (result.valid) { - console.log(`✓ ${relativePath}`); + if (semanticResult?.errors.length) { + console.error(`✗ ${relativePath} (semantic errors)`); + console.error(formatSemanticIssues(semanticResult.errors)); + hasFailure = true; + } else { + console.log(`✓ ${relativePath}`); + } + if (semanticResult?.warnings.length) { + console.warn(" warnings:"); + console.warn(formatSemanticIssues(semanticResult.warnings)); + } } else { console.error(`✗ ${relativePath}`); if (result.errors?.length) { @@ -110,6 +134,28 @@ async function runPluginValidate( } hasFailure = true; } + + if (schemaType === "template-plugins" && result.valid) { + const templateObj = obj as { plugins?: Record }; + if (templateObj.plugins) { + for (const [pluginName, plugin] of Object.entries( + templateObj.plugins, + )) { + const pluginSemantic = runSemanticValidation( + plugin as PluginManifest, + ); + if (pluginSemantic.errors.length) { + console.error(` ✗ plugin "${pluginName}" (semantic errors)`); + console.error(formatSemanticIssues(pluginSemantic.errors)); + hasFailure = true; + } + if (pluginSemantic.warnings.length) { + console.warn(` ⚠ plugin "${pluginName}" (warnings)`); + console.warn(formatSemanticIssues(pluginSemantic.warnings)); + } + } + } + } } process.exit(hasFailure ? 1 : 0); From d8cbbf439739358d826496a9ec84c19915bff1e9 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 8 Apr 2026 18:06:10 +0200 Subject: [PATCH 4/7] feat: annotate core plugin manifests with discovery descriptors and postScaffold steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xavier loop: iteration 4 — Phase 4 (Core Plugin Manifest Annotations) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../src/plugins/analytics/manifest.json | 17 +++- .../appkit/src/plugins/files/manifest.json | 17 +++- .../appkit/src/plugins/genie/manifest.json | 18 ++++- .../appkit/src/plugins/lakebase/manifest.json | 35 +++++++- template/appkit.plugins.json | 80 ++++++++++++++++++- 5 files changed, 156 insertions(+), 11 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/manifest.json b/packages/appkit/src/plugins/analytics/manifest.json index 4a6a60c2..ba717683 100644 --- a/packages/appkit/src/plugins/analytics/manifest.json +++ b/packages/appkit/src/plugins/analytics/manifest.json @@ -14,13 +14,28 @@ "fields": { "id": { "env": "DATABRICKS_WAREHOUSE_ID", - "description": "SQL Warehouse ID" + "description": "SQL Warehouse ID", + "discovery": { + "cliCommand": "databricks warehouses list --profile --output json", + "selectField": ".id", + "displayField": ".name" + } } } } ], "optional": [] }, + "postScaffold": [ + { + "instruction": "Ensure your SQL Warehouse is running and accessible from your workspace.", + "required": true + }, + { + "instruction": "Create a config/queries/ directory and add .sql files for your analytics queries.", + "required": false + } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/files/manifest.json b/packages/appkit/src/plugins/files/manifest.json index c886deca..6442c679 100644 --- a/packages/appkit/src/plugins/files/manifest.json +++ b/packages/appkit/src/plugins/files/manifest.json @@ -14,13 +14,28 @@ "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 --output json", + "selectField": ".full_name", + "displayField": ".name" + } } } } ], "optional": [] }, + "postScaffold": [ + { + "instruction": "Verify your Unity Catalog volume exists and you have WRITE_VOLUME permission.", + "required": true + }, + { + "instruction": "Set the DATABRICKS_VOLUME_FILES environment variable to the full volume path (e.g. /Volumes/catalog/schema/volume_name).", + "required": true + } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/genie/manifest.json b/packages/appkit/src/plugins/genie/manifest.json index a269795d..34ae97e5 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,13 +14,28 @@ "fields": { "id": { "env": "DATABRICKS_GENIE_SPACE_ID", - "description": "Default Genie Space ID" + "description": "Default Genie Space ID", + "discovery": { + "cliCommand": "databricks genie list --profile --output json", + "selectField": ".id", + "displayField": ".name" + } } } } ], "optional": [] }, + "postScaffold": [ + { + "instruction": "Configure the 'spaces' map in your plugin config with alias-to-Space-ID mappings.", + "required": true + }, + { + "instruction": "Ensure your Genie Space(s) are configured with the appropriate data tables and instructions.", + "required": false + } + ], "config": { "schema": { "type": "object", diff --git a/packages/appkit/src/plugins/lakebase/manifest.json b/packages/appkit/src/plugins/lakebase/manifest.json index 2959c092..848c855f 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}"], + "discovery": { + "cliCommand": "databricks postgres list-branches --profile --output json", + "selectField": ".name", + "displayField": ".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}" - ] + ], + "discovery": { + "cliCommand": "databricks postgres list-databases {branch} --profile --output json", + "selectField": ".name", + "displayField": ".name", + "dependsOn": "branch" + } }, "host": { "env": "PGHOST", @@ -42,7 +53,13 @@ "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 --output json", + "selectField": ".name", + "displayField": ".name", + "dependsOn": "branch" + } }, "port": { "env": "PGPORT", @@ -60,5 +77,15 @@ } ], "optional": [] - } + }, + "postScaffold": [ + { + "instruction": "Run database migrations to initialize your Lakebase schema.", + "required": true + }, + { + "instruction": "Verify local connectivity to Lakebase using: PGHOST= PGDATABASE= PGPORT=5432 PGSSLMODE=require psql", + "required": false + } + ] } diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 394e2db1..3d3bd162 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -19,13 +19,28 @@ "id": { "env": "DATABRICKS_WAREHOUSE_ID", "description": "SQL Warehouse ID", + "discovery": { + "cliCommand": "databricks warehouses list --profile --output json", + "selectField": ".id", + "displayField": ".name" + }, "origin": "user" } } } ], "optional": [] - } + }, + "postScaffold": [ + { + "instruction": "Ensure your SQL Warehouse is running and accessible from your workspace.", + "required": true + }, + { + "instruction": "Create a config/queries/ directory and add .sql files for your analytics queries.", + "required": false + } + ] }, "files": { "name": "files", @@ -44,13 +59,28 @@ "path": { "env": "DATABRICKS_VOLUME_FILES", "description": "Volume path for file storage (e.g. /Volumes/catalog/schema/volume_name)", + "discovery": { + "cliCommand": "databricks volumes list . --profile --output json", + "selectField": ".full_name", + "displayField": ".name" + }, "origin": "user" } } } ], "optional": [] - } + }, + "postScaffold": [ + { + "instruction": "Verify your Unity Catalog volume exists and you have WRITE_VOLUME permission.", + "required": true + }, + { + "instruction": "Set the DATABRICKS_VOLUME_FILES environment variable to the full volume path (e.g. /Volumes/catalog/schema/volume_name).", + "required": true + } + ] }, "genie": { "name": "genie", @@ -69,13 +99,28 @@ "id": { "env": "DATABRICKS_GENIE_SPACE_ID", "description": "Default Genie Space ID", + "discovery": { + "cliCommand": "databricks genie list --profile --output json", + "selectField": ".id", + "displayField": ".name" + }, "origin": "user" } } } ], "optional": [] - } + }, + "postScaffold": [ + { + "instruction": "Configure the 'spaces' map in your plugin config with alias-to-Space-ID mappings.", + "required": true + }, + { + "instruction": "Ensure your Genie Space(s) are configured with the appropriate data tables and instructions.", + "required": false + } + ] }, "lakebase": { "name": "lakebase", @@ -96,6 +141,11 @@ "examples": [ "projects/{project-id}/branches/{branch-id}" ], + "discovery": { + "cliCommand": "databricks postgres list-branches --profile --output json", + "selectField": ".name", + "displayField": ".name" + }, "origin": "user" }, "database": { @@ -103,6 +153,12 @@ "examples": [ "projects/{project-id}/branches/{branch-id}/databases/{database-id}" ], + "discovery": { + "cliCommand": "databricks postgres list-databases {branch} --profile --output json", + "selectField": ".name", + "displayField": ".name", + "dependsOn": "branch" + }, "origin": "user" }, "host": { @@ -127,6 +183,12 @@ "examples": [ "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" ], + "discovery": { + "cliCommand": "databricks postgres list-endpoints {branch} --profile --output json", + "selectField": ".name", + "displayField": ".name", + "dependsOn": "branch" + }, "origin": "cli" }, "port": { @@ -147,7 +209,17 @@ } ], "optional": [] - } + }, + "postScaffold": [ + { + "instruction": "Run database migrations to initialize your Lakebase schema.", + "required": true + }, + { + "instruction": "Verify local connectivity to Lakebase using: PGHOST= PGDATABASE= PGPORT=5432 PGSSLMODE=require psql", + "required": false + } + ] }, "server": { "name": "server", From eb6577611324f035c39d8ab32c9c84c1da026c2b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 8 Apr 2026 18:10:24 +0200 Subject: [PATCH 5/7] fix: inline template schema resourceFieldEntry and resourceRequirement for origin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Schema Draft-07 additionalProperties:false blocks allOf composition. Inlined both defs in template schema so origin validates correctly. Xavier loop: iteration 5 — Phase 5 (Integration & Backpressure) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../api/appkit/Interface.PluginManifest.md | 16 + .../appkit/Interface.ResourceFieldEntry.md | 8 + docs/static/appkit-ui/styles.gen.css | 28 +- .../schemas/plugin-manifest.schema.json | 65 ++++ .../schemas/template-plugins.schema.json | 348 +++++++++++++++++- .../src/schemas/template-plugins.schema.json | 255 ++++++++++++- 6 files changed, 707 insertions(+), 13 deletions(-) diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 84ff2487..a16e9ffc 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 list of post-scaffolding instructions shown to the user after project initialization. Array position determines display order. + +#### 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..8eaaf431 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 diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 9a9a38eb..a2192039 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -831,9 +831,6 @@ .max-w-\[calc\(100\%-2rem\)\] { max-width: calc(100% - 2rem); } - .max-w-full { - max-width: 100%; - } .max-w-max { max-width: max-content; } @@ -4514,6 +4511,11 @@ width: calc(var(--spacing) * 5); } } + .\[\&_\[data-slot\=scroll-area-viewport\]\>div\]\:\!block { + & [data-slot=scroll-area-viewport]>div { + display: block !important; + } + } .\[\&_a\]\:underline { & a { text-decoration-line: underline; @@ -4637,11 +4639,26 @@ color: var(--muted-foreground); } } + .\[\&_table\]\:block { + & table { + display: block; + } + } + .\[\&_table\]\:max-w-full { + & table { + max-width: 100%; + } + } .\[\&_table\]\:border-collapse { & table { border-collapse: collapse; } } + .\[\&_table\]\:overflow-x-auto { + & table { + overflow-x: auto; + } + } .\[\&_table\]\:text-xs { & table { font-size: var(--text-xs); @@ -4851,6 +4868,11 @@ width: 100%; } } + .\[\&\>\*\]\:min-w-0 { + &>* { + min-width: calc(var(--spacing) * 0); + } + } .\[\&\>\*\]\:focus-visible\:relative { &>* { &:focus-visible { diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index ed4ef573..5d99a93d 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", + "items": { + "$ref": "#/$defs/postScaffoldStep" + }, + "description": "Ordered list of post-scaffolding instructions shown to the user after project initialization. Array position determines display order." } }, "additionalProperties": false, @@ -220,6 +227,10 @@ "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", + "description": "How the CLI discovers values for this field via a Databricks CLI command." } }, "additionalProperties": false @@ -478,6 +489,60 @@ "type": "boolean" } } + }, + "discoveryDescriptor": { + "type": "object", + "description": "Describes how the CLI discovers values for a resource field via a Databricks CLI command.", + "required": ["cliCommand", "selectField"], + "properties": { + "cliCommand": { + "type": "string", + "description": "Databricks CLI command that lists resources. Must include placeholder.", + "examples": [ + "databricks warehouses list --profile --output json" + ] + }, + "selectField": { + "type": "string", + "description": "jq-style path to the field used as the selected value (e.g., '.id', '.name').", + "examples": [".id", ".name", ".catalog_name"] + }, + "displayField": { + "type": "string", + "description": "jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted.", + "examples": [".name", ".display_name"] + }, + "dependsOn": { + "type": "string", + "description": "Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields.", + "examples": ["branch", "catalog"] + }, + "shortcut": { + "type": "string", + "description": "Single-value fast-path command that returns exactly one value, skipping interactive selection.", + "examples": [ + "databricks warehouses get --profile --output json" + ] + } + }, + "additionalProperties": false + }, + "postScaffoldStep": { + "type": "object", + "description": "A post-scaffolding instruction shown to the user after project initialization.", + "required": ["instruction"], + "properties": { + "instruction": { + "type": "string", + "description": "Human-readable instruction for the user to follow after scaffolding." + }, + "required": { + "type": "boolean", + "default": true, + "description": "Whether this step is required for the plugin to function correctly." + } + }, + "additionalProperties": false } } } diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 290edd05..f83d287a 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -12,7 +12,7 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "2.0"], "description": "Schema version for the template plugins manifest" }, "plugins": { @@ -21,9 +21,24 @@ "additionalProperties": { "$ref": "#/$defs/templatePlugin" } + }, + "scaffolding": { + "$ref": "#/$defs/scaffoldingDescriptor", + "description": "Describes the scaffolding command and its configuration for project initialization." } }, "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "version": { "const": "2.0" } }, + "required": ["version"] + }, + "then": { + "required": ["version", "plugins", "scaffolding"] + } + } + ], "$defs": { "templatePlugin": { "type": "object", @@ -69,6 +84,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", + "items": { + "$ref": "plugin-manifest.schema.json#/$defs/postScaffoldStep" + }, + "description": "Ordered list of post-scaffolding instructions propagated from the plugin manifest." + }, "resources": { "type": "object", "required": ["required", "optional"], @@ -98,10 +120,330 @@ "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" + "type": "object", + "description": "Extends the plugin manifest resourceFieldEntry with a computed origin field for template manifests.", + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field" + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + }, + "bundleIgnore": { + "type": "boolean", + "default": false, + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation." + }, + "examples": { + "type": "array", + "items": { "type": "string" }, + "description": "Example values showing the expected format for this field" + }, + "localOnly": { + "type": "boolean", + "default": false, + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time." + }, + "value": { + "type": "string", + "description": "Static value for this field." + }, + "resolve": { + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$", + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host')." + }, + "discovery": { + "$ref": "plugin-manifest.schema.json#/$defs/discoveryDescriptor", + "description": "How the CLI discovers values for this field via a Databricks CLI command." + }, + "origin": { + "$ref": "#/$defs/origin" + } + }, + "additionalProperties": false }, "resourceRequirement": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" + "type": "object", + "description": "Resource requirement with template-specific field entries (includes computed origin).", + "required": ["type", "alias", "resourceKey", "description", "permission"], + "properties": { + "type": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "type": "string", + "description": "Required permission level." + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to field entry with computed origin." + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "secret" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/secretPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "job" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/jobPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "sql_warehouse" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/sqlWarehousePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "serving_endpoint" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/servingEndpointPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "volume" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/volumePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "vector_search_index" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/vectorSearchIndexPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_function" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/ucFunctionPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_connection" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/ucConnectionPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "database" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/databasePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "postgres" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/postgresPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "genie_space" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/genieSpacePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "experiment" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/experimentPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "app" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/appPermission" + } + } + } + } + ] + }, + "origin": { + "type": "string", + "enum": ["user", "platform", "static", "cli"], + "description": "How the field value is determined. Computed during sync, not authored by plugin developers." + }, + "scaffoldingFlag": { + "type": "object", + "description": "A flag for the scaffolding command.", + "required": ["description"], + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of the flag." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this flag is required." + }, + "pattern": { + "type": "string", + "description": "Regex pattern for validating the flag value." + }, + "default": { + "type": "string", + "description": "Default value for this flag." + } + }, + "additionalProperties": false + }, + "scaffoldingRules": { + "type": "object", + "description": "Structured rules for scaffolding agents.", + "properties": { + "never": { + "type": "array", + "items": { "type": "string" }, + "description": "Actions the scaffolding agent must never perform." + }, + "must": { + "type": "array", + "items": { "type": "string" }, + "description": "Actions the scaffolding agent must always perform." + } + }, + "additionalProperties": false + }, + "scaffoldingDescriptor": { + "type": "object", + "description": "Describes the scaffolding command, flags, and rules for project initialization.", + "required": ["command"], + "properties": { + "command": { + "type": "string", + "description": "The scaffolding command (e.g., 'databricks apps init')." + }, + "flags": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/scaffoldingFlag" + }, + "description": "Map of flag name to flag descriptor." + }, + "rules": { + "$ref": "#/$defs/scaffoldingRules", + "description": "Structured rules for scaffolding agents." + } + }, + "additionalProperties": false } } } diff --git a/packages/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json index 94f377a0..f83d287a 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -120,20 +120,261 @@ "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { + "type": "object", + "description": "Extends the plugin manifest resourceFieldEntry with a computed origin field for template manifests.", + "properties": { + "env": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$", + "description": "Environment variable name for this field" + }, + "description": { + "type": "string", + "description": "Human-readable description for this field" + }, + "bundleIgnore": { + "type": "boolean", + "default": false, + "description": "When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation." + }, + "examples": { + "type": "array", + "items": { "type": "string" }, + "description": "Example values showing the expected format for this field" + }, + "localOnly": { + "type": "boolean", + "default": false, + "description": "When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time." + }, + "value": { + "type": "string", + "description": "Static value for this field." + }, + "resolve": { + "type": "string", + "pattern": "^[a-z_]+:[a-zA-Z]+$", + "description": "Named resolver prefixed by resource type (e.g., 'postgres:host')." + }, + "discovery": { + "$ref": "plugin-manifest.schema.json#/$defs/discoveryDescriptor", + "description": "How the CLI discovers values for this field via a Databricks CLI command." + }, + "origin": { + "$ref": "#/$defs/origin" + } + }, + "additionalProperties": false + }, + "resourceRequirement": { + "type": "object", + "description": "Resource requirement with template-specific field entries (includes computed origin).", + "required": ["type", "alias", "resourceKey", "description", "permission"], + "properties": { + "type": { + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" + }, + "alias": { + "type": "string", + "minLength": 1, + "description": "Human-readable label for UI/display only." + }, + "resourceKey": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Stable key for machine use: deduplication, env naming, composite keys." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of why this resource is needed" + }, + "permission": { + "type": "string", + "description": "Required permission level." + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/resourceFieldEntry" + }, + "minProperties": 1, + "description": "Map of field name to field entry with computed origin." + } + }, + "additionalProperties": false, "allOf": [ - { "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, { - "properties": { - "origin": { - "$ref": "#/$defs/origin" + "if": { + "properties": { "type": { "const": "secret" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/secretPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "job" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/jobPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "sql_warehouse" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/sqlWarehousePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "serving_endpoint" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/servingEndpointPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "volume" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/volumePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "vector_search_index" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/vectorSearchIndexPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_function" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/ucFunctionPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_connection" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/ucConnectionPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "database" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/databasePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "postgres" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/postgresPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "genie_space" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/genieSpacePermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "experiment" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/experimentPermission" + } + } + } + }, + { + "if": { + "properties": { "type": { "const": "app" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { + "$ref": "plugin-manifest.schema.json#/$defs/appPermission" + } } } } ] }, - "resourceRequirement": { - "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" - }, "origin": { "type": "string", "enum": ["user", "platform", "static", "cli"], From d82bd3e458319897a0b1a617962d595482d22c04 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 9 Apr 2026 14:50:12 +0200 Subject: [PATCH 6/7] chore: address code reviews --- .claude/scheduled_tasks.lock | 1 + .../appkit/src/plugins/lakebase/manifest.json | 8 +---- .../src/cli/commands/plugin/manifest-types.ts | 15 +++++++++ .../src/cli/commands/plugin/sync/sync.ts | 29 +++++----------- .../plugin/validate/validate-manifest.ts | 3 +- .../src/schemas/plugin-manifest.generated.ts | 33 +++---------------- .../src/schemas/plugin-manifest.schema.json | 3 +- template/appkit.plugins.json | 6 ---- 8 files changed, 31 insertions(+), 67 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..09304ee9 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"f1aff2d6-c2fa-41a1-9596-0670b1e35bda","pid":6435,"acquiredAt":1775737052293} \ No newline at end of file diff --git a/packages/appkit/src/plugins/lakebase/manifest.json b/packages/appkit/src/plugins/lakebase/manifest.json index 848c855f..b23b1426 100644 --- a/packages/appkit/src/plugins/lakebase/manifest.json +++ b/packages/appkit/src/plugins/lakebase/manifest.json @@ -53,13 +53,7 @@ "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 --output json", - "selectField": ".name", - "displayField": ".name", - "dependsOn": "branch" - } + ] }, "port": { "env": "PGPORT", diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 10b9e947..cf4903fe 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -16,6 +16,7 @@ export type { import type { PluginManifest, PostScaffoldStep, + ResourceFieldEntry, } from "../../../schemas/plugin-manifest.generated"; export interface ScaffoldingFlag { @@ -38,6 +39,20 @@ export interface ScaffoldingDescriptor { export type Origin = "user" | "platform" | "static" | "cli"; +/** + * Derives the origin of a resource field value based on its properties. + * - localOnly: true → "platform" (auto-injected by Databricks Apps platform) + * - value present → "static" (hardcoded value) + * - resolve present → "cli" (resolved by CLI during init) + * - else → "user" (user must provide the value) + */ +export function computeOrigin(field: ResourceFieldEntry): Origin { + if (field.localOnly) return "platform"; + if (field.value !== undefined) return "static"; + if (field.resolve !== undefined) return "cli"; + return "user"; +} + export interface TemplatePlugin extends Omit { package: string; /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index d860b7ac..b9393539 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -7,13 +7,14 @@ import { type ResolvedManifest, resolveManifestInDir, } from "../manifest-resolve"; -import type { - Origin, - PluginManifest, - ResourceFieldEntry, - ScaffoldingDescriptor, - TemplatePlugin, - TemplatePluginsManifest, +import { + computeOrigin, + type Origin, + type PluginManifest, + type ResourceFieldEntry, + type ScaffoldingDescriptor, + type TemplatePlugin, + type TemplatePluginsManifest, } from "../manifest-types"; import { shouldAllowJsManifestForPackage } from "../trusted-js-manifest"; import { @@ -39,20 +40,6 @@ function isWithinDirectory(filePath: string, boundary: string): boolean { ); } -/** - * Derives the origin of a resource field value based on its properties. - * - localOnly: true → "platform" (auto-injected by Databricks Apps platform) - * - value present → "static" (hardcoded value) - * - resolve present → "cli" (resolved by CLI during init) - * - else → "user" (user must provide the value) - */ -function computeOrigin(field: ResourceFieldEntry): Origin { - if (field.localOnly) return "platform"; - if (field.value !== undefined) return "static"; - if (field.resolve !== undefined) return "cli"; - return "user"; -} - /** * Injects computed `origin` onto every resource field in all plugins. * Mutates the plugins object in place for efficiency. 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 35d7ffd2..40ae274c 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts @@ -3,8 +3,7 @@ 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 { computeOrigin } from "../sync/sync"; +import { computeOrigin, type PluginManifest } from "../manifest-types"; export type { PluginManifest }; diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts index 683e9d64..a378cacc 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -257,7 +257,10 @@ export interface ResourceFieldEntry { discovery?: DiscoveryDescriptor; } /** - * How the CLI discovers values for this field via a Databricks CLI command. + * Describes how the CLI discovers values for a resource field via a Databricks CLI command. + * + * This interface was referenced by `PluginManifest`'s JSON-Schema + * via the `definition` "discoveryDescriptor". */ export interface DiscoveryDescriptor { /** @@ -329,31 +332,3 @@ export interface PostScaffoldStep { */ required?: boolean; } -/** - * Describes how the CLI discovers values for a resource field via a Databricks CLI command. - * - * This interface was referenced by `PluginManifest`'s JSON-Schema - * via the `definition` "discoveryDescriptor". - */ -export interface DiscoveryDescriptor1 { - /** - * Databricks CLI command that lists resources. Must include placeholder. - */ - cliCommand: string; - /** - * jq-style path to the field used as the selected value (e.g., '.id', '.name'). - */ - selectField: string; - /** - * jq-style path to the field shown to the user in selection UI. Defaults to selectField if omitted. - */ - displayField?: string; - /** - * Name of a sibling field within the same resource that must be resolved first. Used to express ordering dependencies between resource fields. - */ - dependsOn?: string; - /** - * Single-value fast-path command that returns exactly one value, skipping interactive selection. - */ - shortcut?: string; -} diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index 5d99a93d..2e1134cf 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -229,8 +229,7 @@ "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", - "description": "How the CLI discovers values for this field via a Databricks CLI command." + "$ref": "#/$defs/discoveryDescriptor" } }, "additionalProperties": false diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 3d3bd162..112ad362 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -183,12 +183,6 @@ "examples": [ "projects/{project-id}/branches/{branch-id}/endpoints/{endpoint-id}" ], - "discovery": { - "cliCommand": "databricks postgres list-endpoints {branch} --profile --output json", - "selectField": ".name", - "displayField": ".name", - "dependsOn": "branch" - }, "origin": "cli" }, "port": { From f27eed443fcbbc46f4fbf8a3b1865e391b350206 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 10 Apr 2026 11:42:58 +0200 Subject: [PATCH 7/7] feat: dynamic tarball discovery and .packages/ embedding in prepare-pr-template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xavier loop: iteration 1 — Phase 1 complete --- tools/prepare-pr-template.ts | 152 +++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 26 deletions(-) diff --git a/tools/prepare-pr-template.ts b/tools/prepare-pr-template.ts index 5b00596c..c3b408d3 100644 --- a/tools/prepare-pr-template.ts +++ b/tools/prepare-pr-template.ts @@ -1,57 +1,157 @@ #!/usr/bin/env tsx + /** * Prepares a PR template artifact for testing. * - * Copies the template/ directory into a staging folder, bundles the SDK tarballs - * built by `pnpm pack:sdk`, and rewrites package.json to use `file:` references - * so the template can be tested against the PR's version of appkit/appkit-ui. + * Copies the template/ directory into a staging folder, discovers all SDK + * tarballs built by `pnpm pack:sdk`, and rewrites package.json to use `file:` + * references so the template can be tested against the PR's version of the + * @databricks/* packages. * - * Usage: - * tsx tools/prepare-pr-template.ts + * Tarballs are placed in pr-template/.packages/ (dotfile-prefixed to signal + * infrastructure). Both `dependencies` and `devDependencies` are rewritten + * when a matching tarball is found. * - * The version should match the one used when building the tarballs (e.g. 0.18.0-my-branch). + * Usage: + * tsx tools/prepare-pr-template.ts */ +import { execSync } from "node:child_process"; import { copyFileSync, cpSync, mkdirSync, + readdirSync, readFileSync, + rmSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; const ROOT = process.cwd(); -const version = process.argv[2]; -if (!version) { - console.error("Usage: tsx tools/prepare-pr-template.ts "); +const STAGING_DIR = join(ROOT, "pr-template"); +const PACKAGES_SUBDIR = ".packages"; +const PACKAGES_DIR = join(STAGING_DIR, PACKAGES_SUBDIR); + +// --------------------------------------------------------------------------- +// 1. Discover all tarballs from packages/*/tmp/*.tgz +// --------------------------------------------------------------------------- + +interface TarballInfo { + /** Absolute path to the source tarball */ + sourcePath: string; + /** Filename of the tarball, e.g. "databricks-appkit-0.21.0.tgz" */ + filename: string; + /** npm package name, e.g. "@databricks/appkit" */ + packageName: string; +} + +function discoverTarballs(): TarballInfo[] { + const packagesRoot = join(ROOT, "packages"); + const packageDirs = readdirSync(packagesRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + const tarballs: TarballInfo[] = []; + + for (const dir of packageDirs) { + const tmpDir = join(packagesRoot, dir, "tmp"); + let entries: string[]; + try { + entries = readdirSync(tmpDir); + } catch { + // No tmp/ directory for this package — skip + continue; + } + + for (const entry of entries) { + if (!entry.endsWith(".tgz")) continue; + + const sourcePath = join(tmpDir, entry); + // Tarball filenames follow npm convention: scope-name-version.tgz + // where "@databricks/appkit" becomes "databricks-appkit-.tgz". + // Parse the package name by stripping the "databricks-" prefix and + // the "-.tgz" suffix. The package directory name (e.g. "appkit", + // "appkit-ui", "lakebase") is the reliable source for the package name + // since the tarball lives under packages//tmp/. + const packageName = `@databricks/${dir}`; + + tarballs.push({ sourcePath, filename: entry, packageName }); + } + } + + return tarballs; +} + +const tarballs = discoverTarballs(); +if (tarballs.length === 0) { + console.error( + "No tarballs found in packages/*/tmp/. Did you run `pnpm pack:sdk` first?", + ); process.exit(1); } -const STAGING_DIR = join(ROOT, "pr-template"); -const APPKIT_TARBALL = `databricks-appkit-${version}.tgz`; -const APPKIT_UI_TARBALL = `databricks-appkit-ui-${version}.tgz`; +console.log(`Found ${tarballs.length} tarball(s):`); +for (const t of tarballs) { + console.log(` ${t.packageName} → ${t.filename}`); +} + +// --------------------------------------------------------------------------- +// 2. Copy template into staging directory +// --------------------------------------------------------------------------- -// 1. Copy template into staging directory mkdirSync(STAGING_DIR, { recursive: true }); cpSync(join(ROOT, "template"), STAGING_DIR, { recursive: true }); console.log("✓ Copied template/ → pr-template/"); -// 2. Copy tarballs into staging directory -copyFileSync( - join(ROOT, "packages/appkit/tmp", APPKIT_TARBALL), - join(STAGING_DIR, APPKIT_TARBALL), -); -copyFileSync( - join(ROOT, "packages/appkit-ui/tmp", APPKIT_UI_TARBALL), - join(STAGING_DIR, APPKIT_UI_TARBALL), +// --------------------------------------------------------------------------- +// 3. Create .packages/ subdirectory and copy tarballs into it +// --------------------------------------------------------------------------- + +rmSync(PACKAGES_DIR, { recursive: true, force: true }); +mkdirSync(PACKAGES_DIR, { recursive: true }); + +for (const t of tarballs) { + copyFileSync(t.sourcePath, join(PACKAGES_DIR, t.filename)); +} +console.log( + `✓ Copied ${tarballs.length} tarball(s) into pr-template/${PACKAGES_SUBDIR}/`, ); -console.log(`✓ Copied ${APPKIT_TARBALL} and ${APPKIT_UI_TARBALL}`); -// 3. Rewrite package.json dependencies to point at the local tarballs +// --------------------------------------------------------------------------- +// 4. Rewrite package.json dependencies to point at .packages/ tarballs +// --------------------------------------------------------------------------- + const pkgPath = join(STAGING_DIR, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); -pkg.dependencies["@databricks/appkit"] = `file:./${APPKIT_TARBALL}`; -pkg.dependencies["@databricks/appkit-ui"] = `file:./${APPKIT_UI_TARBALL}`; + +let rewritten = 0; +for (const t of tarballs) { + const fileRef = `file:./${PACKAGES_SUBDIR}/${t.filename}`; + + for (const depField of ["dependencies", "devDependencies"] as const) { + if (pkg[depField]?.[t.packageName]) { + pkg[depField][t.packageName] = fileRef; + rewritten++; + console.log(` ${depField}["${t.packageName}"] → ${fileRef}`); + } + } +} + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); -console.log("✓ Rewrote package.json dependencies to file: references"); +console.log(`✓ Rewrote ${rewritten} dependency reference(s) in package.json`); + +// --------------------------------------------------------------------------- +// 5. Run npm install to regenerate package-lock.json +// --------------------------------------------------------------------------- + +console.log("Running npm install to regenerate package-lock.json…"); +execSync("npm install", { cwd: STAGING_DIR, stdio: "inherit" }); +console.log("✓ package-lock.json regenerated"); + +// --------------------------------------------------------------------------- +// 6. Remove node_modules/ (Databricks CLI reinstalls during init) +// --------------------------------------------------------------------------- + +rmSync(join(STAGING_DIR, "node_modules"), { recursive: true, force: true }); +console.log("✓ Removed pr-template/node_modules/");