From 1c1be5d15ca8d33a60d54b0293882b9f16fb4cb4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:25:44 +0200 Subject: [PATCH 01/12] feat(shared): bake execution capabilities into synced plugin manifests `appkit plugin sync` now resolves the type-level execution facts into appkit.plugins.json so the Go CLI can select user_api_scopes from plain data, with no scope map of its own: - each resource entry gets `scope` (from SCOPE_BY_TYPE) when the type can run on behalf of the user, and `appOnly: true` when it is in APP_ONLY_RESOURCE_TYPES - each plugin entry gets `scopes`, the capability-only scopes from the authoring manifest, omitted when empty All fields are optional and additive, so older CLIs ignore them. Both discovery paths (plugins dir and node_modules scan) now build entries through one `toTemplatePlugin` helper so they cannot drift apart again. Regenerates the template-plugins JSON schema and template manifest. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../schemas/template-plugins.schema.json | 277 ++++++++++++++++++ .../plugin/sync/sync-capabilities.test.ts | 100 +++++++ .../src/cli/commands/plugin/sync/sync.ts | 100 ++++--- packages/shared/src/schemas/manifest.ts | 20 ++ template/appkit.plugins.json | 24 +- 5 files changed, 469 insertions(+), 52 deletions(-) create mode 100644 packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 435614e84..7e41f2f18 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -262,6 +262,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["READ", "WRITE", "MANAGE"], @@ -433,6 +443,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -604,6 +624,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -775,6 +805,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -946,6 +986,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -1117,6 +1167,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -1288,6 +1348,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -1459,6 +1529,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -1630,6 +1710,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -1801,6 +1891,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -1972,6 +2072,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": [ @@ -2148,6 +2258,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -2319,6 +2439,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_USE"], @@ -2499,6 +2629,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["READ", "WRITE", "MANAGE"], @@ -2670,6 +2810,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -2841,6 +2991,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -3012,6 +3172,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -3183,6 +3353,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -3354,6 +3534,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -3525,6 +3715,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -3696,6 +3896,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -3867,6 +4077,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4038,6 +4258,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4209,6 +4439,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": [ @@ -4385,6 +4625,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -4556,6 +4806,16 @@ "additionalProperties": false } }, + "scope": { + "description": "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + "type": "string", + "minLength": 1 + }, + "appOnly": { + "description": "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + "type": "boolean", + "const": true + }, "permission": { "type": "string", "enum": ["CAN_USE"], @@ -4580,6 +4840,23 @@ "required": ["required", "optional"], "additionalProperties": false, "description": "Databricks resource requirements for this plugin" + }, + "scopes": { + "description": "Capability-only user_api_scopes with no resource ID, copied from the plugin manifest. Omitted when empty.", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "ai-gateway", + "mcp.external", + "mcp.functions", + "workspace.workspace", + "catalog.catalogs:read", + "catalog.schemas:read", + "catalog.tables:read" + ] + } } }, "required": [ diff --git a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts new file mode 100644 index 000000000..d4cec5d75 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { scanForPlugins, scanPluginsDir } from "./sync"; + +const probe = { + name: "probe", + displayName: "Probe", + description: "Execution capability probe", + scopes: ["ai-gateway"], + resources: { + required: [ + { + type: "sql_warehouse", + alias: "Warehouse", + resourceKey: "sql-warehouse", + description: "OBO-capable resource", + permission: "CAN_USE", + fields: { id: { env: "DATABRICKS_WAREHOUSE_ID" } }, + }, + ], + optional: [ + { + type: "secret", + alias: "Secret", + resourceKey: "secret", + description: "App-only resource", + permission: "READ", + fields: { scope: { env: "SECRET_SCOPE" }, key: { env: "SECRET_KEY" } }, + }, + { + type: "job", + alias: "Job", + resourceKey: "job", + description: "SP-only resource with no scope", + permission: "CAN_MANAGE_RUN", + fields: { id: { env: "DATABRICKS_JOB_ID" } }, + }, + ], + }, +}; + +const plain = { ...probe, name: "plain", scopes: undefined }; + +function writeManifest(dir: string, manifest: object) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest)); +} + +describe("sync execution capabilities", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "appkit-sync-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const paths = { + "plugins dir (loadPluginEntry)": async () => { + writeManifest(path.join(tmp, "plugins", "probe"), probe); + writeManifest(path.join(tmp, "plugins", "plain"), plain); + return scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false); + }, + "node_modules scan (scanForPlugins)": async () => { + const pluginsDir = path.join( + tmp, + "node_modules", + "@x/pkg", + "dist", + "plugins", + ); + writeManifest(path.join(pluginsDir, "probe"), probe); + writeManifest(path.join(pluginsDir, "plain"), plain); + return scanForPlugins(tmp, ["@x/pkg"], false); + }, + }; + + it.each(Object.entries(paths))( + "%s bakes scope, appOnly, and scopes", + async (_, scan) => { + const plugins = await scan(); + const [warehouse] = plugins.probe.resources.required; + const [secret, job] = plugins.probe.resources.optional; + + expect(warehouse.scope).toBe("sql"); + expect(warehouse).not.toHaveProperty("appOnly"); + expect(secret.appOnly).toBe(true); + expect(secret).not.toHaveProperty("scope"); + expect(job).not.toHaveProperty("scope"); + expect(job).not.toHaveProperty("appOnly"); + expect(plugins.probe.scopes).toEqual(["ai-gateway"]); + expect(plugins.plain).not.toHaveProperty("scopes"); + }, + ); +}); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 4227e5e81..842b9107e 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -6,6 +6,8 @@ import type { SgNode } from "@ast-grep/napi"; import { Command } from "commander"; import { + APP_ONLY_RESOURCE_TYPES, + SCOPE_BY_TYPE, TEMPLATE_SCAFFOLDING, templateFieldEntrySchema, } from "../../../../schemas/manifest"; @@ -80,32 +82,57 @@ async function loadPluginEntry( const manifest = validateManifestWithSchema(parsed, resolved.path); if (!manifest || manifest.hidden) return null; - return [ - manifest.name, - { - name: manifest.name, - displayName: manifest.displayName, - description: manifest.description, - package: pkg, - resources: manifest.resources, - ...(manifest.onSetupMessage && { - onSetupMessage: manifest.onSetupMessage, - }), - // Narrowing on `!== "ga"` removes "ga"; the truthy check - // removes `undefined`. What's left is the non-GA tier set, - // which TypeScript already knows is assignable to TemplatePlugin's - // `stability` field — so no cast is needed and adding a future - // tier (e.g. "alpha") flows through type-correctly. - ...(manifest.stability && - manifest.stability !== "ga" && { - stability: manifest.stability, - }), - ...(manifest.deprecated && { deprecated: manifest.deprecated }), - ...(manifest.scaffolding && { - scaffolding: manifest.scaffolding, - }), + return [manifest.name, toTemplatePlugin(manifest, pkg)]; +} + +type ManifestResource = PluginManifest["resources"]["required"][number]; + +/** Bake the type-level execution facts into the resource so the CLI reads plain data. */ +function withExecutionCapabilities(resource: ManifestResource) { + const scope = Object.hasOwn(SCOPE_BY_TYPE, resource.type) + ? SCOPE_BY_TYPE[resource.type as keyof typeof SCOPE_BY_TYPE] + : undefined; + return { + ...resource, + ...(scope && { scope }), + ...(APP_ONLY_RESOURCE_TYPES.has(resource.type) && { + appOnly: true as const, + }), + }; +} + +/** Build a TemplatePlugin entry. Every discovery path goes through here. */ +function toTemplatePlugin( + manifest: PluginManifest, + pkg: string, +): TemplatePlugin { + return { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: pkg, + resources: { + required: manifest.resources.required.map(withExecutionCapabilities), + optional: manifest.resources.optional.map(withExecutionCapabilities), }, - ]; + ...(manifest.scopes?.length && { scopes: manifest.scopes }), + ...(manifest.onSetupMessage && { + onSetupMessage: manifest.onSetupMessage, + }), + // Narrowing on `!== "ga"` removes "ga"; the truthy check + // removes `undefined`. What's left is the non-GA tier set, + // which TypeScript already knows is assignable to TemplatePlugin's + // `stability` field — so no cast is needed and adding a future + // tier (e.g. "alpha") flows through type-correctly. + ...(manifest.stability && + manifest.stability !== "ga" && { + stability: manifest.stability, + }), + ...(manifest.deprecated && { deprecated: manifest.deprecated }), + ...(manifest.scaffolding && { + scaffolding: manifest.scaffolding, + }), + }; } /** @@ -424,24 +451,7 @@ async function scanForPlugins( ); for (const manifest of manifests) { if (manifest.hidden) continue; - plugins[manifest.name] = { - name: manifest.name, - displayName: manifest.displayName, - description: manifest.description, - package: packageName, - resources: manifest.resources, - ...(manifest.onSetupMessage && { - onSetupMessage: manifest.onSetupMessage, - }), - ...(manifest.stability && - manifest.stability !== "ga" && { - stability: manifest.stability, - }), - ...(manifest.deprecated && { deprecated: manifest.deprecated }), - ...(manifest.scaffolding && { - scaffolding: manifest.scaffolding, - }), - } satisfies TemplatePlugin; + plugins[manifest.name] = toTemplatePlugin(manifest, packageName); } } @@ -879,11 +889,13 @@ 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, discovery paths. */ export { isWithinDirectory, parseImports, parsePluginUsages, + scanForPlugins, + scanPluginsDir, shouldAllowJsManifestForPackage, }; diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index af97f0521..40124c65e 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -882,6 +882,19 @@ const templateResourceRequirementBaseShape = { }) .optional() .describe("Map of field name to field entry with computed origin."), + scope: z + .string() + .min(1) + .optional() + .describe( + "Apps user_api_scope for this resource type. Present only when the type can run on behalf of the user. Resolved by sync from SCOPE_BY_TYPE.", + ), + appOnly: z + .literal(true) + .optional() + .describe( + "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", + ), }; function makeTemplateResourceVariant< @@ -1021,6 +1034,13 @@ export const templatePluginSchema = z }) .strict() .describe("Databricks resource requirements for this plugin"), + scopes: z + .array(capabilityScopeSchema) + .min(1) + .optional() + .describe( + "Capability-only user_api_scopes with no resource ID, copied from the plugin manifest. Omitted when empty.", + ), }) .strict() .describe("Plugin manifest with package source information"); diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 61a3e0b12..859ff1941 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -22,7 +22,8 @@ "description": "Default LLM serving endpoint name", "origin": "user" } - } + }, + "scope": "model-serving" }, { "type": "experiment", @@ -55,7 +56,8 @@ }, "origin": "user" } - } + }, + "scope": "files" } ] }, @@ -80,7 +82,8 @@ "description": "Three-level UC name of the default index (catalog.schema.index_name)", "origin": "user" } - } + }, + "scope": "vector-search" } ], "optional": [] @@ -119,7 +122,8 @@ }, "origin": "user" } - } + }, + "scope": "sql" } ], "optional": [] @@ -159,7 +163,8 @@ }, "origin": "user" } - } + }, + "scope": "files" } ], "optional": [] @@ -199,7 +204,8 @@ "description": "Genie Space display name", "origin": "user" } - } + }, + "scope": "genie" } ], "optional": [] @@ -328,7 +334,8 @@ "value": "require", "origin": "platform" } - } + }, + "appOnly": true } ], "optional": [] @@ -372,7 +379,8 @@ "description": "Serving endpoint name", "origin": "user" } - } + }, + "scope": "model-serving" } ], "optional": [] From e66387a92186475dfad77708ebd0800c6eba30c2 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:25:58 +0200 Subject: [PATCH 02/12] feat: let the CLI supply user_api_scopes to the app template databricks.yml.tmpl now renders `.bundle.userApiScopes` under user_api_scopes when the generator provides it (pre-rendered list items, generator owns indentation, same convention as `.bundle.resources`). When the value is unset, the existing plugin-presence block renders unchanged, so an older CLI produces exactly today's output. A guard test pins that fallback block. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../sync/template-user-api-scopes.test.ts | 39 +++++++++++++++++++ template/databricks.yml.tmpl | 5 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts diff --git a/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts new file mode 100644 index 000000000..5cae81d1b --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const template = fs.readFileSync( + path.resolve(__dirname, "../../../../../../../template/databricks.yml.tmpl"), + "utf-8", +); + +// Old CLIs never set .bundle.userApiScopes, so this branch must stay byte-for-byte. +const LEGACY_BLOCK = `{{- else if or .plugins.genie .plugins.files .plugins.serving}} + user_api_scopes: +{{- if .plugins.genie}} + - dashboards.genie +{{- end}} +{{- if .plugins.files}} + - files.files +{{- end}} +{{- if .plugins.serving}} + - serving.serving-endpoints +{{- end}} +{{- else}} + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # user_api_scopes: + # - sql +{{- end}}`; + +describe("template databricks.yml user_api_scopes", () => { + it("renders generator-provided scopes when set", () => { + expect(template).toContain( + "{{- if .bundle.userApiScopes}}\n user_api_scopes:\n{{.bundle.userApiScopes}}\n", + ); + }); + + it("falls back to the unchanged plugin-presence block", () => { + expect(template).toContain(`{{.bundle.userApiScopes}}\n${LEGACY_BLOCK}`); + }); +}); diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index a646a4376..6a7490666 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -17,7 +17,10 @@ resources: lifecycle: started: true -{{- if or .plugins.genie .plugins.files .plugins.serving}} +{{- if .bundle.userApiScopes}} + user_api_scopes: +{{.bundle.userApiScopes}} +{{- else if or .plugins.genie .plugins.files .plugins.serving}} user_api_scopes: {{- if .plugins.genie}} - dashboards.genie From 7a1576719717f3e0df6e635ad533bdc1b5c91d8f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:26:53 +0200 Subject: [PATCH 03/12] refactor(shared): derive registry add scope warnings from SCOPE_BY_TYPE `appkit add` kept its own SCOPE_BY_RESOURCE_TYPE map with the deprecated long scope names (dashboards.genie, files.files, serving.serving-endpoints) and only three types. It now reads SCOPE_BY_TYPE, so there is one map and the deploy hint names the same scope the synced manifest carries, including sql, vector-search, and catalog.connections. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../src/cli/commands/registry/add.test.ts | 8 ++++---- .../shared/src/cli/commands/registry/add.ts | 17 +++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/cli/commands/registry/add.test.ts b/packages/shared/src/cli/commands/registry/add.test.ts index 0143ca447..0822357bf 100644 --- a/packages/shared/src/cli/commands/registry/add.test.ts +++ b/packages/shared/src/cli/commands/registry/add.test.ts @@ -236,14 +236,14 @@ describe("scopesForResources", () => { resourceRow("volume"), ]); expect(Object.fromEntries(scopes)).toEqual({ - genie_space: "dashboards.genie", - serving_endpoint: "serving.serving-endpoints", - volume: "files.files", + genie_space: "genie", + serving_endpoint: "model-serving", + volume: "files", }); }); it("returns empty for resources that need no scope", () => { - expect(scopesForResources([resourceRow("sql_warehouse")]).size).toBe(0); + expect(scopesForResources([resourceRow("job")]).size).toBe(0); }); it("de-dupes repeated types", () => { diff --git a/packages/shared/src/cli/commands/registry/add.ts b/packages/shared/src/cli/commands/registry/add.ts index d8592e7f5..310b041d6 100644 --- a/packages/shared/src/cli/commands/registry/add.ts +++ b/packages/shared/src/cli/commands/registry/add.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { Command } from "commander"; import pc from "picocolors"; +import { SCOPE_BY_TYPE } from "../../../schemas/manifest"; import { fetchRegistryItem, fetchVerifiedNames, @@ -688,22 +689,18 @@ async function runAdd(refs: string[], opts: AddOptions): Promise { * extension). Warn when an added plugin's resource type is known to need one, * so the user adds it before deploy. */ -/** Resource types known to require a user_api_scope, and the scope each needs. */ -export const SCOPE_BY_RESOURCE_TYPE: Record = { - genie_space: "dashboards.genie", - serving_endpoint: "serving.serving-endpoints", - // volumes/files-backed access uses files.files - volume: "files.files", -}; - /** Returns the user_api_scopes implied by a set of resource rows (deduped). */ export function scopesForResources( rows: ResourceRequirementRow[], ): Map { const needed = new Map(); for (const row of rows) { - const scope = SCOPE_BY_RESOURCE_TYPE[row.type]; - if (scope) needed.set(row.type, scope); + if (Object.hasOwn(SCOPE_BY_TYPE, row.type)) { + needed.set( + row.type, + SCOPE_BY_TYPE[row.type as keyof typeof SCOPE_BY_TYPE], + ); + } } return needed; } From b2050ef626f21a4ab52532cf40ba8b33977c92b5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 30 Sep 2026 12:51:31 +0200 Subject: [PATCH 04/12] fix(shared): declare user scopes for plugins that always act as the user genie and serving run every route on behalf of the user, whatever the resource is bound as. In a mixed app where the CLI emits user_api_scopes from OBO resources only, their scope was missing and every request failed closed. Plugins now declare such scopes as data in their manifest `scopes`, which the CLI already unions whenever any resource is OBO: - genie: ["genie"] - serving: ["model-serving"] analytics (.obo.sql lanes), files and ai-search (per-volume or per-index `auth` config, default service principal), and agents (tools carry their own scopes; the model call runs as the service principal) only act as the user when something opts in, so they declare nothing. The plugin `scopes` enum widens from the seven capability-only scopes to every user_api_scope. Widening accepts more manifests and rejects none that were valid before. The all-SP default is unchanged: the CLI emits no user_api_scopes there and the template keeps its plugin-presence block. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../api/appkit/Interface.PluginManifest.md | 10 ++++++- .../schemas/plugin-manifest.schema.json | 10 ++++++- .../schemas/template-plugins.schema.json | 10 ++++++- .../appkit/src/plugins/genie/manifest.json | 1 + .../appkit/src/plugins/serving/manifest.json | 1 + .../plugin/sync/sync-capabilities.test.ts | 15 ++++++++++ packages/shared/src/schemas/manifest.test.ts | 20 ++++++------- packages/shared/src/schemas/manifest.ts | 30 ++++++++++++++++--- template/appkit.plugins.json | 6 ++++ 9 files changed, 86 insertions(+), 17 deletions(-) diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 32762abeb..405ef5189 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -297,13 +297,21 @@ Omit.scaffolding ```ts optional scopes: ( + | "postgres" + | "sql" + | "model-serving" + | "genie" + | "files" + | "vector-search" + | "catalog.connections" | "ai-gateway" | "mcp.external" | "mcp.functions" | "workspace.workspace" | "catalog.catalogs:read" | "catalog.schemas:read" - | "catalog.tables:read")[]; + | "catalog.tables:read" + | "sql:restricted-query")[]; ``` #### Inherited from diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index fd726d41a..0cad35eff 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -5,11 +5,19 @@ "type": "object", "properties": { "scopes": { - "description": "Capability-only user_api_scopes with no resource ID.", + "description": "user_api_scopes the plugin always needs, whatever its resources are bound as: calls it makes on behalf of the user unconditionally, or capabilities with no resource ID.", "type": "array", "items": { "type": "string", "enum": [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", "ai-gateway", "mcp.external", "mcp.functions", diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 7e41f2f18..c887f9104 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -4842,12 +4842,20 @@ "description": "Databricks resource requirements for this plugin" }, "scopes": { - "description": "Capability-only user_api_scopes with no resource ID, copied from the plugin manifest. Omitted when empty.", + "description": "user_api_scopes the plugin always needs, copied from the plugin manifest. Omitted when empty.", "minItems": 1, "type": "array", "items": { "type": "string", "enum": [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", "ai-gateway", "mcp.external", "mcp.functions", diff --git a/packages/appkit/src/plugins/genie/manifest.json b/packages/appkit/src/plugins/genie/manifest.json index f62ba6116..bc42be21c 100644 --- a/packages/appkit/src/plugins/genie/manifest.json +++ b/packages/appkit/src/plugins/genie/manifest.json @@ -3,6 +3,7 @@ "name": "genie", "displayName": "Genie Plugin", "description": "AI/BI Genie space integration for natural language data queries", + "scopes": ["genie"], "resources": { "required": [ { diff --git a/packages/appkit/src/plugins/serving/manifest.json b/packages/appkit/src/plugins/serving/manifest.json index 3605e5104..4fcfd2f4a 100644 --- a/packages/appkit/src/plugins/serving/manifest.json +++ b/packages/appkit/src/plugins/serving/manifest.json @@ -4,6 +4,7 @@ "displayName": "Model Serving Plugin (deprecated)", "description": "DEPRECATED: use the agents plugin instead. Authenticated proxy to Databricks Model Serving endpoints", "deprecated": true, + "scopes": ["model-serving"], "resources": { "required": [ { diff --git a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts index d4cec5d75..d0d0d2b94 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts @@ -97,4 +97,19 @@ describe("sync execution capabilities", () => { expect(plugins.plain).not.toHaveProperty("scopes"); }, ); + + it("core plugins declare the scopes they always use on behalf of the user", async () => { + const plugins = await scanPluginsDir( + path.resolve(__dirname, "../../../../../../appkit/src/plugins"), + "@databricks/appkit", + false, + ); + // genie and serving routes always run as the user, whatever the resource binding. + expect(plugins.genie.scopes).toEqual(["genie"]); + expect(plugins.serving.scopes).toEqual(["model-serving"]); + // These only act as the user when a resource or config opts in, so they stay unscoped. + for (const name of ["analytics", "files", "aiSearch", "agents"]) { + expect(plugins[name]).not.toHaveProperty("scopes"); + } + }); }); diff --git a/packages/shared/src/schemas/manifest.test.ts b/packages/shared/src/schemas/manifest.test.ts index 13f2939f8..b6d9ce9e7 100644 --- a/packages/shared/src/schemas/manifest.test.ts +++ b/packages/shared/src/schemas/manifest.test.ts @@ -37,8 +37,16 @@ describe("manifest execution capabilities", () => { ).toEqual([]); }); - test("accepts every capability-only scope without resource IDs", () => { + test("accepts every user_api_scope as an authored plugin scope", () => { const scopes = [ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", "ai-gateway", "mcp.external", "mcp.functions", @@ -53,14 +61,6 @@ describe("manifest execution capabilities", () => { }); test.each([ - "sql", - "sql:restricted-query", - "postgres", - "genie", - "model-serving", - "files", - "vector-search", - "catalog.connections", "mlflow", "jobs", "apps", @@ -68,7 +68,7 @@ describe("manifest execution capabilities", () => { "files.files", "serving.serving-endpoints", "unknown", - ])("rejects %s as an authored capability-only scope", (scope) => { + ])("rejects %s as an authored plugin scope", (scope) => { expect( pluginManifestSchema.safeParse({ ...manifest, scopes: [scope] }).success, ).toBe(false); diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 40124c65e..40c0d4dd2 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -85,6 +85,26 @@ export const capabilityScopeSchema = z.enum([ export type CapabilityScope = z.infer; +/** + * Every Apps user_api_scope (short names; the long forms such as + * `dashboards.genie` are deprecated aliases). A plugin declares one in its + * `scopes` when it always calls on behalf of the user, or when the capability + * has no resource ID. + */ +export const userApiScopeSchema = z.enum([ + "sql", + "sql:restricted-query", + "genie", + "postgres", + "model-serving", + "files", + "vector-search", + "catalog.connections", + ...capabilityScopeSchema.options, +]); + +export type UserApiScope = z.infer; + export const secretPermissionSchema = z .enum(["READ", "WRITE", "MANAGE"]) .describe("Permission for secret resources (order: weakest to strongest)"); @@ -702,9 +722,11 @@ export const pluginScaffoldingRulesSchema = z export const pluginManifestSchema = z .object({ scopes: z - .array(capabilityScopeSchema) + .array(userApiScopeSchema) .optional() - .describe("Capability-only user_api_scopes with no resource ID."), + .describe( + "user_api_scopes the plugin always needs, whatever its resources are bound as: calls it makes on behalf of the user unconditionally, or capabilities with no resource ID.", + ), $schema: z .string() .optional() @@ -1035,11 +1057,11 @@ export const templatePluginSchema = z .strict() .describe("Databricks resource requirements for this plugin"), scopes: z - .array(capabilityScopeSchema) + .array(userApiScopeSchema) .min(1) .optional() .describe( - "Capability-only user_api_scopes with no resource ID, copied from the plugin manifest. Omitted when empty.", + "user_api_scopes the plugin always needs, copied from the plugin manifest. Omitted when empty.", ), }) .strict() diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 859ff1941..7ed2fdaad 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -210,6 +210,9 @@ ], "optional": [] }, + "scopes": [ + "genie" + ], "scaffolding": { "rules": { "must": [ @@ -385,6 +388,9 @@ ], "optional": [] }, + "scopes": [ + "model-serving" + ], "deprecated": true } }, From da702994f374982c5d94ed51d0241b933c19e7d8 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 14:10:58 +0200 Subject: [PATCH 05/12] refactor(shared): drop the unused CapabilityScope type export After the plugin `scopes` fields moved to userApiScopeSchema, the exported CapabilityScope type has no consumer. The capabilityScopeSchema value stays, since its options feed userApiScopeSchema. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- packages/shared/src/schemas/manifest.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 40c0d4dd2..3e11c1742 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -83,8 +83,6 @@ export const capabilityScopeSchema = z.enum([ "catalog.tables:read", ]); -export type CapabilityScope = z.infer; - /** * Every Apps user_api_scope (short names; the long forms such as * `dashboards.genie` are deprecated aliases). A plugin declares one in its From d8f7602a3678e264d52ac7a45be1aa1bc0f18a94 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 16:48:46 +0200 Subject: [PATCH 06/12] docs(appkit): refresh the template user_api_scopes example to short names The commented example in databricks.yml.tmpl listed the deprecated long scope names. Update it to the current short names (sql, genie, files, model-serving). Comment only; the active plugin-presence fallback block is unchanged. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- template/databricks.yml.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index 6a7490666..4398b71ca 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -32,7 +32,7 @@ resources: - serving.serving-endpoints {{- end}} {{- else}} - # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, genie, files, model-serving # user_api_scopes: # - sql {{- end}} From 7870aa4604b39bf5abbc4b8a8d418a88b4c20b3b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 18:23:46 +0200 Subject: [PATCH 07/12] test(appkit): align the template fallback pin with the short-name comment The template comment was updated to short scope names, but this test still pinned the old long-name comment, so the fallback-block assertion failed. Update the expected block. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../cli/commands/plugin/sync/template-user-api-scopes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts index 5cae81d1b..db1c16b15 100644 --- a/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/template-user-api-scopes.test.ts @@ -21,7 +21,7 @@ const LEGACY_BLOCK = `{{- else if or .plugins.genie .plugins.files .plugins.serv - serving.serving-endpoints {{- end}} {{- else}} - # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, genie, files, model-serving # user_api_scopes: # - sql {{- end}}`; From 4608af511f04f2bfe83d43d535ff480828e69587 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 18:24:06 +0200 Subject: [PATCH 08/12] feat(shared): bake DABs binding spec into synced plugin manifests Move the CLI's per-type databricks.yml binding spec into the manifest so AppKit owns it and the CLI consumes it as data instead of a hardcoded map. - Add DABS_BINDING_BY_TYPE next to SCOPE_BY_TYPE: a faithful port of the CLI's appResourceSpecs, keyed by resource type, with { yamlKey, varFields: [[manifestField, dabsField]], staticFields?: [[dabsField, value]] }. App-only types still bind (as the service principal). The app type is absent, matching the CLI's commented-out entry. - Add an optional per-resource `binding` object to the template resource schema. Additive and optional, so older CLIs ignore it. - sync resolves binding from DABS_BINDING_BY_TYPE and bakes it onto each resource, materializing mutable tuples so nothing shares a reference with the source table. - Re-sync template/appkit.plugins.json: every resource now carries binding. - Regenerate the template-plugins JSON schema. - Tests: both sync paths bake the expected binding for sql_warehouse, secret, and job, and the real core manifests bake genie_space and the volume uc_securable static field. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../schemas/template-plugins.schema.json | 1118 +++++++++++++++++ .../plugin/sync/sync-capabilities.test.ts | 38 + .../src/cli/commands/plugin/sync/sync.ts | 23 + packages/shared/src/schemas/manifest.ts | 104 ++ template/appkit.plugins.json | 132 +- 5 files changed, 1407 insertions(+), 8 deletions(-) diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index c887f9104..55ea25270 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -272,6 +272,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ", "WRITE", "MANAGE"], @@ -453,6 +496,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -634,6 +720,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -815,6 +944,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -996,6 +1168,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -1177,6 +1392,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -1358,6 +1616,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -1539,6 +1840,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -1720,6 +2064,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -1901,6 +2288,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -2082,6 +2512,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": [ @@ -2268,6 +2741,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -2449,6 +2965,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE"], @@ -2639,6 +3198,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ", "WRITE", "MANAGE"], @@ -2820,6 +3422,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], @@ -3001,6 +3646,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE", "CAN_MANAGE"], @@ -3182,6 +3870,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_VIEW", "CAN_QUERY", "CAN_MANAGE"], @@ -3363,6 +4094,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["READ_VOLUME", "WRITE_VOLUME"], @@ -3544,6 +4318,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["SELECT"], @@ -3725,6 +4542,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["EXECUTE"], @@ -3906,6 +4766,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["USE_CONNECTION"], @@ -4087,6 +4990,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4268,6 +5214,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_CONNECT_AND_CREATE"], @@ -4449,6 +5438,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": [ @@ -4635,6 +5667,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], @@ -4816,6 +5891,49 @@ "type": "boolean", "const": true }, + "binding": { + "description": "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + "type": "object", + "properties": { + "yamlKey": { + "type": "string", + "minLength": 1, + "description": "DABs YAML key under the resource entry." + }, + "varFields": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "description": "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField." + }, + "staticFields": { + "description": "[dabsField, value] constant pairs.", + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["yamlKey", "varFields"], + "additionalProperties": false + }, "permission": { "type": "string", "enum": ["CAN_USE"], diff --git a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts index d0d0d2b94..332eb6048 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts @@ -95,6 +95,23 @@ describe("sync execution capabilities", () => { expect(job).not.toHaveProperty("appOnly"); expect(plugins.probe.scopes).toEqual(["ai-gateway"]); expect(plugins.plain).not.toHaveProperty("scopes"); + + // DABs binding is baked per resource from DABS_BINDING_BY_TYPE. + expect(warehouse.binding).toEqual({ + yamlKey: "sql_warehouse", + varFields: [["id", "id"]], + }); + expect(secret.binding).toEqual({ + yamlKey: "secret", + varFields: [ + ["scope", "scope"], + ["key", "key"], + ], + }); + expect(job.binding).toEqual({ + yamlKey: "job", + varFields: [["id", "id"]], + }); }, ); @@ -111,5 +128,26 @@ describe("sync execution capabilities", () => { for (const name of ["analytics", "files", "aiSearch", "agents"]) { expect(plugins[name]).not.toHaveProperty("scopes"); } + + // DABs binding is baked from the real core manifests, including the + // uc_securable static field for volumes. + const genieSpace = plugins.genie.resources.required.find( + (r: { type: string }) => r.type === "genie_space", + ); + expect(genieSpace?.binding).toEqual({ + yamlKey: "genie_space", + varFields: [ + ["name", "name"], + ["id", "space_id"], + ], + }); + const volume = plugins.files.resources.required.find( + (r: { type: string }) => r.type === "volume", + ); + expect(volume?.binding).toEqual({ + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "VOLUME"]], + }); }); }); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 842b9107e..448cb2ad5 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -7,6 +7,8 @@ import { Command } from "commander"; import { APP_ONLY_RESOURCE_TYPES, + DABS_BINDING_BY_TYPE, + type ResourceBinding, SCOPE_BY_TYPE, TEMPLATE_SCAFFOLDING, templateFieldEntrySchema, @@ -92,12 +94,33 @@ function withExecutionCapabilities(resource: ManifestResource) { const scope = Object.hasOwn(SCOPE_BY_TYPE, resource.type) ? SCOPE_BY_TYPE[resource.type as keyof typeof SCOPE_BY_TYPE] : undefined; + const binding: ResourceBinding | undefined = Object.hasOwn( + DABS_BINDING_BY_TYPE, + resource.type, + ) + ? DABS_BINDING_BY_TYPE[resource.type as keyof typeof DABS_BINDING_BY_TYPE] + : undefined; return { ...resource, ...(scope && { scope }), ...(APP_ONLY_RESOURCE_TYPES.has(resource.type) && { appOnly: true as const, }), + ...(binding && { + // Materialize mutable tuples so the baked entry matches the template + // schema and never shares a reference with the source const table. + binding: { + yamlKey: binding.yamlKey, + varFields: binding.varFields.map( + (pair) => [pair[0], pair[1]] as [string, string], + ), + ...(binding.staticFields && { + staticFields: binding.staticFields.map( + (pair) => [pair[0], pair[1]] as [string, string], + ), + }), + }, + }), }; } diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 3e11c1742..ad49cb3cb 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -72,6 +72,89 @@ export const APP_ONLY_RESOURCE_TYPES: ReadonlySet = new Set([ "postgres", ]); +/** + * How a resource type binds in `databricks.yml` as a DABs app resource. Owned + * here so the CLI consumes it as data instead of hardcoding a per-type map. + * + * - `yamlKey`: the DABs YAML key under the resource entry (e.g. `sql_warehouse`, + * `uc_securable`). + * - `varFields`: `[manifestField, dabsField]` pairs. Each becomes a + * `${var._}` reference written to `dabsField`. + * - `staticFields`: `[dabsField, value]` constant pairs (e.g. + * `securable_type` = `VOLUME`). + * + * Permission is not here; it stays the per-resource `permission` field. + */ +export interface ResourceBinding { + readonly yamlKey: string; + readonly varFields: ReadonlyArray; + readonly staticFields?: ReadonlyArray; +} + +/** + * DABs binding spec per resource type. Faithful port of the CLI's + * `appResourceSpecs`. App-only types still bind (as the service principal). The + * `app` type is intentionally absent: bundles do not yet support it as an app + * resource, matching the commented-out CLI entry. + */ +export const DABS_BINDING_BY_TYPE = { + sql_warehouse: { yamlKey: "sql_warehouse", varFields: [["id", "id"]] }, + job: { yamlKey: "job", varFields: [["id", "id"]] }, + serving_endpoint: { + yamlKey: "serving_endpoint", + varFields: [["name", "name"]], + }, + experiment: { yamlKey: "experiment", varFields: [["id", "experiment_id"]] }, + secret: { + yamlKey: "secret", + varFields: [ + ["scope", "scope"], + ["key", "key"], + ], + }, + database: { + yamlKey: "database", + varFields: [ + ["instance_name", "instance_name"], + ["database_name", "database_name"], + ], + }, + postgres: { + yamlKey: "postgres", + varFields: [ + ["branch", "branch"], + ["database", "database"], + ], + }, + genie_space: { + yamlKey: "genie_space", + varFields: [ + ["name", "name"], + ["id", "space_id"], + ], + }, + volume: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "VOLUME"]], + }, + uc_function: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "FUNCTION"]], + }, + uc_connection: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "CONNECTION"]], + }, + vector_search_index: { + yamlKey: "uc_securable", + varFields: [["id", "securable_full_name"]], + staticFields: [["securable_type", "TABLE"]], + }, +} as const satisfies Partial>; + /** Capabilities that need a user_api_scope but have no resource ID. */ export const capabilityScopeSchema = z.enum([ "ai-gateway", @@ -915,6 +998,27 @@ const templateResourceRequirementBaseShape = { .describe( "Present only when the type always runs as the app service principal and must be bound (secret, database, postgres). Resolved by sync from APP_ONLY_RESOURCE_TYPES.", ), + binding: z + .object({ + yamlKey: z + .string() + .min(1) + .describe("DABs YAML key under the resource entry."), + varFields: z + .array(z.tuple([z.string(), z.string()])) + .describe( + "[manifestField, dabsField] pairs. Each becomes ${var._} written to dabsField.", + ), + staticFields: z + .array(z.tuple([z.string(), z.string()])) + .optional() + .describe("[dabsField, value] constant pairs."), + }) + .strict() + .optional() + .describe( + "How this resource type binds in databricks.yml as a DABs app resource. Resolved by sync from DABS_BINDING_BY_TYPE.", + ), }; function makeTemplateResourceVariant< diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 7ed2fdaad..4b0ea83d5 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -23,7 +23,16 @@ "origin": "user" } }, - "scope": "model-serving" + "scope": "model-serving", + "binding": { + "yamlKey": "serving_endpoint", + "varFields": [ + [ + "name", + "name" + ] + ] + } }, { "type": "experiment", @@ -37,6 +46,15 @@ "description": "MLflow experiment id traces are logged to", "origin": "user" } + }, + "binding": { + "yamlKey": "experiment", + "varFields": [ + [ + "id", + "experiment_id" + ] + ] } }, { @@ -57,7 +75,22 @@ "origin": "user" } }, - "scope": "files" + "scope": "files", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "id", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "VOLUME" + ] + ] + } } ] }, @@ -83,7 +116,22 @@ "origin": "user" } }, - "scope": "vector-search" + "scope": "vector-search", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "id", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "TABLE" + ] + ] + } } ], "optional": [] @@ -123,7 +171,16 @@ "origin": "user" } }, - "scope": "sql" + "scope": "sql", + "binding": { + "yamlKey": "sql_warehouse", + "varFields": [ + [ + "id", + "id" + ] + ] + } } ], "optional": [] @@ -164,7 +221,22 @@ "origin": "user" } }, - "scope": "files" + "scope": "files", + "binding": { + "yamlKey": "uc_securable", + "varFields": [ + [ + "id", + "securable_full_name" + ] + ], + "staticFields": [ + [ + "securable_type", + "VOLUME" + ] + ] + } } ], "optional": [] @@ -205,7 +277,20 @@ "origin": "user" } }, - "scope": "genie" + "scope": "genie", + "binding": { + "yamlKey": "genie_space", + "varFields": [ + [ + "name", + "name" + ], + [ + "id", + "space_id" + ] + ] + } } ], "optional": [] @@ -240,6 +325,15 @@ "description": "Numeric Databricks job ID. Find it in the Jobs UI or via `databricks jobs list`.", "origin": "user" } + }, + "binding": { + "yamlKey": "job", + "varFields": [ + [ + "id", + "id" + ] + ] } } ], @@ -338,7 +432,20 @@ "origin": "platform" } }, - "appOnly": true + "appOnly": true, + "binding": { + "yamlKey": "postgres", + "varFields": [ + [ + "branch", + "branch" + ], + [ + "database", + "database" + ] + ] + } } ], "optional": [] @@ -383,7 +490,16 @@ "origin": "user" } }, - "scope": "model-serving" + "scope": "model-serving", + "binding": { + "yamlKey": "serving_endpoint", + "varFields": [ + [ + "name", + "name" + ] + ] + } } ], "optional": [] From a2cf53c45f38262084b065f2bb3a773def678707 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 18:36:22 +0200 Subject: [PATCH 09/12] refactor(shared): anchor DABs binding yamlKeys with an SDK seam Add a compile-time drift anchor for DABS_BINDING_BY_TYPE yamlKeys, isolated to one SDK-version-dependent seam so finishing it after the SDK migration is a three-line change in a single place. - The seam holds a local AppResource shim (the installed sdk-experimental 0.17 does not export the apps AppResource types) and the single skew exception union AppResourceKind = keyof AppResource | "postgres" | "experiment" | "app", with a TODO(sdk-migration) listing the finish steps: repoint the import to the SDK apps AppResource, drop the exceptions, and tighten to a bare keyof AppResource. - A type-only AssertAllTrue check fails the build if any binding yamlKey is not a known AppResource kind. Types-only: DABS_BINDING_BY_TYPE values, sync output, and the baked template manifest are unchanged. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- packages/shared/src/schemas/manifest.ts | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index ad49cb3cb..7a4bf875f 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -91,6 +91,41 @@ export interface ResourceBinding { readonly staticFields?: ReadonlyArray; } +// ── SDK coupling seam (the only SDK-version-dependent bit) ─────────────── +// +// TODO(sdk-migration): once @databricks/sdk-experimental exports the apps +// AppResource model, finish the anchor here, in this one spot: +// 1. replace the local `AppResource` shim below with +// `import type { AppResource } from "@databricks/sdk-experimental"` +// (the apis/apps model); +// 2. drop the "postgres" | "experiment" | "app" skew exceptions from +// `AppResourceKind` (the new SDK models those kinds); +// 3. tighten `AppResourceKind` to a bare `keyof AppResource`. +// Nothing else needs to change: the drift assertion below already checks every +// `DABS_BINDING_BY_TYPE` yamlKey against `AppResourceKind`. +// +// The installed sdk-experimental (0.17) does not export the apps AppResource +// types, so this shim mirrors the DABs resource kinds the SDK models. The +// values are unused; only the keys anchor the yamlKeys. +interface AppResource { + sql_warehouse?: unknown; + serving_endpoint?: unknown; + genie_space?: unknown; + job?: unknown; + secret?: unknown; + database?: unknown; + uc_securable?: unknown; +} + +/** + * DABs app-resource YAML keys. `keyof AppResource` is the SDK-anchored set; + * `postgres`/`experiment`/`app` are kinds AppKit supports that the installed + * SDK does not model yet (see the TODO above). + */ +type AppResourceKind = keyof AppResource | "postgres" | "experiment" | "app"; + +// ── end seam ───────────────────────────────────────────────────────────── + /** * DABs binding spec per resource type. Faithful port of the CLI's * `appResourceSpecs`. App-only types still bind (as the service principal). The @@ -155,6 +190,17 @@ export const DABS_BINDING_BY_TYPE = { }, } as const satisfies Partial>; +// Compile-time drift anchor (type-only, no runtime effect): every binding +// yamlKey must be a known AppResource kind. If the SDK renames a kind after +// the seam above is wired, or a new entry uses an unknown yamlKey, the failing +// entry resolves to `false` and this fails to satisfy the all-true constraint. +type AssertAllTrue> = T; +type _DabsYamlKeysAnchored = AssertAllTrue<{ + [K in keyof typeof DABS_BINDING_BY_TYPE]: (typeof DABS_BINDING_BY_TYPE)[K]["yamlKey"] extends AppResourceKind + ? true + : false; +}>; + /** Capabilities that need a user_api_scope but have no resource ID. */ export const capabilityScopeSchema = z.enum([ "ai-gateway", From 99912c348283bc1090bb945353089562bff8ccba Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 18:53:44 +0200 Subject: [PATCH 10/12] refactor(shared): drop the SDK shim, leave a plain table with an accurate TODO The CLI side owns the real Apps SDK anchoring (Go reflection of apps.AppResource, available now), and the JS SDK apps types are not importable until the modular migration. So no TS shim should linger. Remove the local AppResource shim, the AppResourceKind union, and the AssertAllTrue drift check. DABS_BINDING_BY_TYPE stays a plain table, now carrying a TODO(sdk-migration) grounded in the real target shape (@databricks/sdk-apps AppResource $case union) with the exact finish steps. Types and comment only: the baked template manifest and runtime output are unchanged, and every resource still carries its binding. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- packages/shared/src/schemas/manifest.ts | 55 ++++--------------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index 7a4bf875f..f88685840 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -91,46 +91,20 @@ export interface ResourceBinding { readonly staticFields?: ReadonlyArray; } -// ── SDK coupling seam (the only SDK-version-dependent bit) ─────────────── -// -// TODO(sdk-migration): once @databricks/sdk-experimental exports the apps -// AppResource model, finish the anchor here, in this one spot: -// 1. replace the local `AppResource` shim below with -// `import type { AppResource } from "@databricks/sdk-experimental"` -// (the apis/apps model); -// 2. drop the "postgres" | "experiment" | "app" skew exceptions from -// `AppResourceKind` (the new SDK models those kinds); -// 3. tighten `AppResourceKind` to a bare `keyof AppResource`. -// Nothing else needs to change: the drift assertion below already checks every -// `DABS_BINDING_BY_TYPE` yamlKey against `AppResourceKind`. -// -// The installed sdk-experimental (0.17) does not export the apps AppResource -// types, so this shim mirrors the DABs resource kinds the SDK models. The -// values are unused; only the keys anchor the yamlKeys. -interface AppResource { - sql_warehouse?: unknown; - serving_endpoint?: unknown; - genie_space?: unknown; - job?: unknown; - secret?: unknown; - database?: unknown; - uc_securable?: unknown; -} - -/** - * DABs app-resource YAML keys. `keyof AppResource` is the SDK-anchored set; - * `postgres`/`experiment`/`app` are kinds AppKit supports that the installed - * SDK does not model yet (see the TODO above). - */ -type AppResourceKind = keyof AppResource | "postgres" | "experiment" | "app"; - -// ── end seam ───────────────────────────────────────────────────────────── - /** * DABs binding spec per resource type. Faithful port of the CLI's * `appResourceSpecs`. App-only types still bind (as the service principal). The * `app` type is intentionally absent: bundles do not yet support it as an app * resource, matching the commented-out CLI entry. + * + * TODO(sdk-migration): anchor these yamlKeys to the Apps SDK once the modular migration + * (analytics-migration-sdk / #562) adds @databricks/sdk-apps: + * 1. add @databricks/sdk-apps; re-export AppResource via packages/shared/src/workspace-client/modular.ts + * (direct @databricks/sdk-* imports are banned by the repo lint rule) + * 2. the new AppResource is a $case union: kinds = NonNullable["$case"] + * (camelCase: sqlWarehouse | servingEndpoint | genieSpace | ucSecurable | ...) + * 3. map camelCase $case -> snake_case yamlKey and assert every table entry is covered + * (the new SDK models postgres/experiment/app, so the old skew exceptions are not needed) */ export const DABS_BINDING_BY_TYPE = { sql_warehouse: { yamlKey: "sql_warehouse", varFields: [["id", "id"]] }, @@ -190,17 +164,6 @@ export const DABS_BINDING_BY_TYPE = { }, } as const satisfies Partial>; -// Compile-time drift anchor (type-only, no runtime effect): every binding -// yamlKey must be a known AppResource kind. If the SDK renames a kind after -// the seam above is wired, or a new entry uses an unknown yamlKey, the failing -// entry resolves to `false` and this fails to satisfy the all-true constraint. -type AssertAllTrue> = T; -type _DabsYamlKeysAnchored = AssertAllTrue<{ - [K in keyof typeof DABS_BINDING_BY_TYPE]: (typeof DABS_BINDING_BY_TYPE)[K]["yamlKey"] extends AppResourceKind - ? true - : false; -}>; - /** Capabilities that need a user_api_scope but have no resource ID. */ export const capabilityScopeSchema = z.enum([ "ai-gateway", From 984bb2d00cd87f569565e422cba59ab458b4cebe Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 1 Oct 2026 19:07:05 +0200 Subject: [PATCH 11/12] fix(shared): correct volume/experiment binding fields and guard the rest The binding specs referenced manifest field `id` for two types whose resources declare a different field, so apps init emitted ${var._id} against an unset variable: - volume: now [["path", "securable_full_name"]] (declares `path`) - experiment: now [["experimentId", "experiment_id"]] (declares `experimentId`) yamlKey and staticFields are unchanged. This corrects the baked binding for files, agents-skills (volumes) and agents-mlflow-experiment. Add a sync-time guard: when baking binding, assert every varFields manifestField is one of the resource's declared fields, and throw a clear error naming plugin/resource/field otherwise. The error is a distinct BindingFieldError that discovery re-throws instead of swallowing, so a misconfig fails sync. A unit test covers a resource whose binding references an undeclared field. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../plugin/sync/sync-capabilities.test.ts | 30 +++++++++++++++- .../src/cli/commands/plugin/sync/sync.ts | 36 +++++++++++++++++-- packages/shared/src/schemas/manifest.ts | 7 ++-- template/appkit.plugins.json | 6 ++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts index 332eb6048..0feb1a389 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync-capabilities.test.ts @@ -146,8 +146,36 @@ describe("sync execution capabilities", () => { ); expect(volume?.binding).toEqual({ yamlKey: "uc_securable", - varFields: [["id", "securable_full_name"]], + // volume declares `path`, not `id`. + varFields: [["path", "securable_full_name"]], staticFields: [["securable_type", "VOLUME"]], }); }); + + it("fails sync when a binding references a field the resource does not declare", async () => { + const bad = { + name: "badbind", + displayName: "Bad binding", + description: "Binding references an undeclared field", + resources: { + required: [ + { + type: "sql_warehouse", + alias: "Warehouse", + resourceKey: "sql-warehouse", + description: "sql_warehouse binding expects field `id`", + permission: "CAN_USE", + // Declares `region`, not `id`, so the baked binding varField `id` + // would reference an unset variable. + fields: { region: { env: "DATABRICKS_REGION" } }, + }, + ], + optional: [], + }, + }; + writeManifest(path.join(tmp, "plugins", "badbind"), bad); + await expect( + scanPluginsDir(path.join(tmp, "plugins"), "@x/pkg", false), + ).rejects.toThrow(/binding references manifest field "id"/); + }); }); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 448cb2ad5..439107931 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -89,8 +89,18 @@ async function loadPluginEntry( type ManifestResource = PluginManifest["resources"]["required"][number]; +/** + * A binding spec references a manifest field the resource does not declare. + * Distinct class so discovery can re-throw it instead of swallowing it as a + * generic "failed to load manifest" warning. + */ +export class BindingFieldError extends Error {} + /** Bake the type-level execution facts into the resource so the CLI reads plain data. */ -function withExecutionCapabilities(resource: ManifestResource) { +function withExecutionCapabilities( + resource: ManifestResource, + pluginName: string, +) { const scope = Object.hasOwn(SCOPE_BY_TYPE, resource.type) ? SCOPE_BY_TYPE[resource.type as keyof typeof SCOPE_BY_TYPE] : undefined; @@ -100,6 +110,19 @@ function withExecutionCapabilities(resource: ManifestResource) { ) ? DABS_BINDING_BY_TYPE[resource.type as keyof typeof DABS_BINDING_BY_TYPE] : undefined; + if (binding) { + // Guard: every binding manifestField must be a declared field on the + // resource, otherwise the generator emits ${var._} against a + // variable nothing sets. This is the appkit analog of the CLI SDK anchor. + const declared = new Set(Object.keys(resource.fields ?? {})); + for (const [manifestField] of binding.varFields) { + if (!declared.has(manifestField)) { + throw new BindingFieldError( + `Plugin "${pluginName}" resource "${resource.resourceKey}" (${resource.type}): binding references manifest field "${manifestField}", which the resource does not declare. Declared fields: ${[...declared].join(", ") || "(none)"}. Fix DABS_BINDING_BY_TYPE or the resource fields.`, + ); + } + } + } return { ...resource, ...(scope && { scope }), @@ -135,8 +158,12 @@ function toTemplatePlugin( description: manifest.description, package: pkg, resources: { - required: manifest.resources.required.map(withExecutionCapabilities), - optional: manifest.resources.optional.map(withExecutionCapabilities), + required: manifest.resources.required.map((resource) => + withExecutionCapabilities(resource, manifest.name), + ), + optional: manifest.resources.optional.map((resource) => + withExecutionCapabilities(resource, manifest.name), + ), }, ...(manifest.scopes?.length && { scopes: manifest.scopes }), ...(manifest.onSetupMessage && { @@ -565,6 +592,9 @@ async function scanPluginsDir( const pluginEntry = await loadPluginEntry(resolved, pkg, allowJsManifest); if (pluginEntry) plugins[pluginEntry[0]] = pluginEntry[1]; } catch (error) { + // A binding/field misconfig is a developer error, not a flaky manifest + // load; fail hard instead of warning and dropping the plugin. + if (error instanceof BindingFieldError) throw error; console.warn( `Warning: Failed to load manifest at ${resolved.path}:`, error instanceof Error ? error.message : error, diff --git a/packages/shared/src/schemas/manifest.ts b/packages/shared/src/schemas/manifest.ts index f88685840..1263d4023 100644 --- a/packages/shared/src/schemas/manifest.ts +++ b/packages/shared/src/schemas/manifest.ts @@ -113,7 +113,10 @@ export const DABS_BINDING_BY_TYPE = { yamlKey: "serving_endpoint", varFields: [["name", "name"]], }, - experiment: { yamlKey: "experiment", varFields: [["id", "experiment_id"]] }, + experiment: { + yamlKey: "experiment", + varFields: [["experimentId", "experiment_id"]], + }, secret: { yamlKey: "secret", varFields: [ @@ -144,7 +147,7 @@ export const DABS_BINDING_BY_TYPE = { }, volume: { yamlKey: "uc_securable", - varFields: [["id", "securable_full_name"]], + varFields: [["path", "securable_full_name"]], staticFields: [["securable_type", "VOLUME"]], }, uc_function: { diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 4b0ea83d5..db53b96b7 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -51,7 +51,7 @@ "yamlKey": "experiment", "varFields": [ [ - "id", + "experimentId", "experiment_id" ] ] @@ -80,7 +80,7 @@ "yamlKey": "uc_securable", "varFields": [ [ - "id", + "path", "securable_full_name" ] ], @@ -226,7 +226,7 @@ "yamlKey": "uc_securable", "varFields": [ [ - "id", + "path", "securable_full_name" ] ], From 6781872e0686e1fa6bbded4130bfaba2e97dddf1 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 5 Oct 2026 15:19:00 +0200 Subject: [PATCH 12/12] docs(appkit): explain genie's forced scope and the legacy template scope fallback Genie routes always run on behalf of the user, so its manifest declares a plugin-level genie scope. The template's plugin scope block remains the scope source for older CLIs and for pure service-principal mode. Both notes record why the coupling exists and when it can be removed. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- packages/appkit/src/plugins/genie/genie.ts | 9 +++++++++ template/databricks.yml.tmpl | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 351d3988e..66dfd0b79 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -130,6 +130,15 @@ export class GeniePlugin extends Plugin implements ToolProvider { return this.config.spaces?.[alias] ?? null; } + /** + * Every route runs on behalf of the requesting user, whatever auth mode the + * genie_space resource is bound with. That is why manifest.json declares the + * plugin-level `scopes: ["genie"]`: the user token always needs the genie + * scope, even when the space is bound to the service principal. If these + * routes ever follow the resource's auth mode (as analytics does with + * `.obo.sql`), drop that plugin-level scope and let the resource-level scope + * from SCOPE_BY_TYPE cover it. + */ injectRoutes(router: IAppRouter) { this.route(router, { name: "sendMessage", diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index 4398b71ca..d5b27e251 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -17,6 +17,12 @@ resources: lifecycle: started: true +{{- /* The `else if` plugin block below is a legacy fallback. It is still the + only scope source for CLIs older than per-resource auth mode (they never set + .bundle.userApiScopes) and for the new CLI in pure service-principal mode, + where a genie app still needs a user scope because genie routes always run + on behalf of the user. Remove it once the minimum supported CLI includes + auth mode AND the CLI emits plugin-level scopes in service-principal mode. */}} {{- if .bundle.userApiScopes}} user_api_scopes: {{.bundle.userApiScopes}}