diff --git a/CLAUDE.md b/CLAUDE.md index 9a17f870..2e67c3a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,17 @@ pnpm check:fix # Auto-fix with Biome pnpm typecheck # TypeScript type checking across all packages ``` +### AppKit CLI +When using the published SDK or running from the monorepo (after `pnpm build`), the `appkit` CLI is available: + +```bash +npx appkit plugin sync --write # Sync plugin manifests into appkit.plugins.json +npx appkit plugin create # Scaffold a new plugin (interactive, uses @clack/prompts) +npx appkit plugin validate # Validate manifest(s) against the JSON schema +npx appkit plugin list # List plugins (from appkit.plugins.json or --dir) +npx appkit plugin add-resource # Add a resource requirement to a plugin (interactive) +``` + ### Deployment ```bash pnpm pack:sdk # Package SDK for deployment diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 3bfe1067..1b78ef79 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -205,9 +205,97 @@ const AppKit = await createApp({ For complete configuration options, see [`createApp`](api/appkit/Function.createApp.md). +## Plugin CLI + +AppKit includes a CLI for managing plugins. All commands are available under `npx appkit plugin`. + +### Create a plugin + +Scaffold a new plugin interactively: + +```bash +npx appkit plugin create +``` + +The wizard walks you through: +- **Placement**: In your repository (e.g. `plugins/my-plugin`) or as a standalone package +- **Metadata**: Name, display name, description +- **Resources**: Which Databricks resources the plugin needs (SQL Warehouse, Secret, etc.) and whether each is required or optional +- **Optional fields**: Author, version, license + +The command generates a complete plugin scaffold with `manifest.json`, TypeScript class, and barrel exports — ready to register in your app. + +### Sync plugin manifests + +Scan your project for plugins and generate `appkit.plugins.json`: + +```bash +npx appkit plugin sync --write +``` + +This discovers plugin manifests from installed packages and local imports, then writes a consolidated manifest used by deployment tooling. Plugins referenced in your `createApp({ plugins: [...] })` call are automatically marked as required. + +Use the `--silent` flag in build hooks to suppress output: + +```json +{ + "scripts": { + "sync": "appkit plugin sync --write --silent", + "predev": "npm run sync", + "prebuild": "npm run sync" + } +} +``` + +### Validate manifests + +Check plugin manifests against the JSON schema: + +```bash +# Validate manifest.json in the current directory +npx appkit plugin validate + +# Validate specific files or directories +npx appkit plugin validate plugins/my-plugin appkit.plugins.json +``` + +The validator auto-detects whether a file is a plugin manifest or a template manifest (from `$schema`) and reports errors with humanized paths and expected values. + +### List plugins + +View registered plugins from `appkit.plugins.json` or scan a directory: + +```bash +# From appkit.plugins.json (default) +npx appkit plugin list + +# Scan a directory for plugin folders +npx appkit plugin list --dir plugins/ + +# JSON output for scripting +npx appkit plugin list --json +``` + +### Add a resource to a plugin + +Interactively add a new resource requirement to an existing plugin manifest: + +```bash +npx appkit plugin add-resource + +# Or specify the plugin directory +npx appkit plugin add-resource --path plugins/my-plugin +``` + ## Creating custom plugins -If you need custom API routes or background logic, implement an AppKit plugin. +If you need custom API routes or background logic, implement an AppKit plugin. The fastest way is to use the CLI: + +```bash +npx appkit plugin create +``` + +For a deeper understanding of the plugin structure, read on. ### Basic plugin example diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 8f8c9feb..2411f72e 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -168,24 +168,6 @@ "enum": ["CAN_USE"], "description": "Permission for Databricks App resources" }, - "resourcePermission": { - "type": "string", - "description": "Permission level required for the resource. Valid values depend on resource type.", - "oneOf": [ - { "$ref": "#/$defs/secretPermission" }, - { "$ref": "#/$defs/jobPermission" }, - { "$ref": "#/$defs/sqlWarehousePermission" }, - { "$ref": "#/$defs/servingEndpointPermission" }, - { "$ref": "#/$defs/volumePermission" }, - { "$ref": "#/$defs/vectorSearchIndexPermission" }, - { "$ref": "#/$defs/ucFunctionPermission" }, - { "$ref": "#/$defs/ucConnectionPermission" }, - { "$ref": "#/$defs/databasePermission" }, - { "$ref": "#/$defs/genieSpacePermission" }, - { "$ref": "#/$defs/experimentPermission" }, - { "$ref": "#/$defs/appPermission" } - ] - }, "resourceFieldEntry": { "type": "object", "required": ["env"], @@ -219,13 +201,13 @@ }, "alias": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", + "minLength": 1, "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", "examples": ["SQL Warehouse", "Secret", "Vector search index"] }, "resourceKey": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", + "pattern": "^[a-z][a-z0-9-]*$", "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", "examples": ["sql-warehouse", "database", "secret"] }, @@ -235,7 +217,8 @@ "description": "Human-readable description of why this resource is needed" }, "permission": { - "$ref": "#/$defs/resourcePermission" + "type": "string", + "description": "Required permission level. Validated per resource type by the allOf/if-then rules below." }, "fields": { "type": "object", @@ -246,7 +229,137 @@ "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "secret" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/secretPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "job" } }, + "required": ["type"] + }, + "then": { + "properties": { "permission": { "$ref": "#/$defs/jobPermission" } } + } + }, + { + "if": { + "properties": { "type": { "const": "sql_warehouse" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/sqlWarehousePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "serving_endpoint" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/servingEndpointPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "volume" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/volumePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "vector_search_index" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/vectorSearchIndexPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_function" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/ucFunctionPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_connection" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/ucConnectionPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "database" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/databasePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "genie_space" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/genieSpacePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "experiment" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/experimentPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "app" } }, + "required": ["type"] + }, + "then": { + "properties": { "permission": { "$ref": "#/$defs/appPermission" } } + } + } + ] }, "configSchemaProperty": { "type": "object", diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index f6bb5ef8..9713e9f6 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -91,89 +91,13 @@ "additionalProperties": false }, "resourceType": { - "type": "string", - "enum": [ - "secret", - "job", - "sql_warehouse", - "serving_endpoint", - "volume", - "vector_search_index", - "uc_function", - "uc_connection", - "database", - "genie_space", - "experiment", - "app" - ], - "description": "Type of Databricks resource" - }, - "resourcePermission": { - "type": "string", - "description": "Permission level required for the resource. Valid values depend on resource type.", - "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "type": "object", - "required": ["env"], - "properties": { - "env": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]*$", - "description": "Environment variable name for this field", - "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] - }, - "description": { - "type": "string", - "description": "Human-readable description for this field" - } - }, - "additionalProperties": false + "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, "resourceRequirement": { - "type": "object", - "required": [ - "type", - "alias", - "resourceKey", - "description", - "permission", - "fields" - ], - "properties": { - "type": { - "$ref": "#/$defs/resourceType" - }, - "alias": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin (UI/display)", - "examples": ["SQL Warehouse", "Secret", "Vector search index"] - }, - "resourceKey": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Stable key for machine use (env naming, composite keys, app.yaml).", - "examples": ["sql-warehouse", "database", "secret"] - }, - "description": { - "type": "string", - "minLength": 1, - "description": "Human-readable description of why this resource is needed" - }, - "permission": { - "$ref": "#/$defs/resourcePermission" - }, - "fields": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/resourceFieldEntry" - }, - "minProperties": 1, - "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." - } - }, - "additionalProperties": false + "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" } } } diff --git a/package.json b/package.json index a88ea962..9f557673 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "version": "0.0.2", "packageManager": "pnpm@10.21.0", "scripts": { - "build": "pnpm -r --filter=!docs build:package", + "build": "pnpm -r --filter=!docs build:package && pnpm sync:template", + "sync:template": "node packages/shared/bin/appkit.js plugin sync --write --silent --plugins-dir packages/appkit/src/plugins --output template/appkit.plugins.json --require-plugins server", "build:watch": "pnpm -r --filter=!dev-playground --filter=!docs build:watch", "check:fix": "biome check --write .", "check": "biome check .", diff --git a/packages/shared/package.json b/packages/shared/package.json index df890905..d09ef87f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -25,6 +25,7 @@ "@types/dependency-tree": "^8.1.4", "@types/express": "^4.17.21", "@types/json-schema": "^7.0.15", + "@types/node": "^25.2.3", "@types/ws": "^8.18.1", "dependency-tree": "^11.2.0" }, @@ -42,6 +43,7 @@ "@ast-grep/napi": "^0.37.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "@clack/prompts": "^1.0.1", "commander": "^12.1.0" } } diff --git a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts new file mode 100644 index 00000000..33be67df --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts @@ -0,0 +1,183 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { cancel, intro, isCancel, outro, select, text } from "@clack/prompts"; +import { Command } from "commander"; +import { + getDefaultFieldsForType, + humanizeResourceType, + PERMISSIONS_BY_TYPE, + RESOURCE_TYPE_OPTIONS, + resourceKeyFromType, +} from "../create/resource-defaults"; +import type { PluginManifest } from "../manifest-types"; +import { validateManifest } from "../validate/validate-manifest"; + +/** Extended manifest type that preserves extra JSON fields (e.g. $schema, author, version) for round-trip writes. */ +interface ManifestWithExtras extends PluginManifest { + [key: string]: unknown; +} + +async function runPluginAddResource(options: { path?: string }): Promise { + intro("Add resource to plugin manifest"); + + const cwd = process.cwd(); + const pluginDir = path.resolve(cwd, options.path ?? "."); + const manifestPath = path.join(pluginDir, "manifest.json"); + + if (!fs.existsSync(manifestPath)) { + console.error(`manifest.json not found at ${manifestPath}`); + process.exit(1); + } + + let raw: string; + let manifest: ManifestWithExtras; + try { + raw = fs.readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const result = validateManifest(parsed); + if (!result.valid || !result.manifest) { + console.error( + "Invalid manifest. Run `appkit plugin validate` for details.", + ); + process.exit(1); + } + manifest = parsed as ManifestWithExtras; + } catch (err) { + console.error( + "Failed to read or parse manifest.json:", + err instanceof Error ? err.message : err, + ); + process.exit(1); + } + + const resourceType = await select({ + message: "Resource type", + options: RESOURCE_TYPE_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + })), + }); + if (isCancel(resourceType)) { + cancel("Cancelled."); + process.exit(0); + } + + const required = await select({ + message: "Required or optional?", + options: [ + { value: true, label: "Required", hint: "plugin needs it to function" }, + { value: false, label: "Optional", hint: "enhances functionality" }, + ], + }); + if (isCancel(required)) { + cancel("Cancelled."); + process.exit(0); + } + + const description = await text({ + message: "Short description for this resource", + placeholder: required ? "Required for …" : "Optional for …", + }); + if (isCancel(description)) { + cancel("Cancelled."); + process.exit(0); + } + + const type = resourceType as string; + const alias = humanizeResourceType(type); + const defaultKey = resourceKeyFromType(type); + + const resourceKey = await text({ + message: "Resource key (unique identifier within the manifest)", + initialValue: defaultKey, + placeholder: defaultKey, + validate: (val = "") => { + if (!val.trim()) return "Resource key is required"; + if (!/^[a-z][a-z0-9-]*$/.test(val)) + return "Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens"; + }, + }); + if (isCancel(resourceKey)) { + cancel("Cancelled."); + process.exit(0); + } + + const typePermissions = PERMISSIONS_BY_TYPE[type] ?? ["CAN_VIEW"]; + let permission: string; + if (typePermissions.length === 1) { + permission = typePermissions[0]; + } else { + const selected = await select({ + message: "Permission level", + options: typePermissions.map((p) => ({ value: p, label: p })), + }); + if (isCancel(selected)) { + cancel("Cancelled."); + process.exit(0); + } + permission = selected as string; + } + + const defaultFields = getDefaultFieldsForType(type); + const fields: Record = {}; + + for (const [fieldKey, defaults] of Object.entries(defaultFields)) { + const envName = await text({ + message: `Env var for "${fieldKey}"${defaults.description ? ` (${defaults.description})` : ""}`, + initialValue: defaults.env, + placeholder: defaults.env, + validate: (val = "") => { + if (!val.trim()) return "Env var name is required"; + if (!/^[A-Z][A-Z0-9_]*$/.test(val)) + return "Must be uppercase, start with a letter (e.g. DATABRICKS_WAREHOUSE_ID)"; + }, + }); + if (isCancel(envName)) { + cancel("Cancelled."); + process.exit(0); + } + fields[fieldKey] = { + env: (envName as string).trim(), + ...(defaults.description ? { description: defaults.description } : {}), + }; + } + + const entry = { + type, + alias, + resourceKey: (resourceKey as string).trim(), + description: + (description as string)?.trim() || `Required for ${alias} functionality.`, + permission, + fields, + }; + + if (required) { + manifest.resources.required.push(entry); + } else { + manifest.resources.optional.push(entry); + } + + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + outro("Resource added."); + console.log( + `\nAdded ${alias} as ${required ? "required" : "optional"} to ${path.relative(cwd, manifestPath)}`, + ); +} + +export const pluginAddResourceCommand = new Command("add-resource") + .description( + "Add a resource requirement to an existing plugin manifest (interactive). Overwrites manifest.json in place.", + ) + .option( + "-p, --path ", + "Plugin directory containing manifest.json (default: .)", + ) + .action((opts) => + runPluginAddResource(opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts new file mode 100644 index 00000000..81a05e27 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -0,0 +1,285 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { + cancel, + confirm, + intro, + isCancel, + multiselect, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import { Command } from "commander"; +import { + humanizeResourceType, + RESOURCE_TYPE_OPTIONS, +} from "./resource-defaults"; +import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; +import type { CreateAnswers, Placement, SelectedResource } from "./types"; + +const NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +const DEFAULT_VERSION = "1.0.0"; + +async function runPluginCreate(): Promise { + intro("Create a new AppKit plugin"); + + try { + const placement = await select({ + message: "Where should the plugin live?", + options: [ + { + value: "in-repo", + label: "In this repository (e.g. plugins/my-plugin)", + hint: "folder path", + }, + { + value: "isolated", + label: "New isolated package", + hint: "full package with package.json", + }, + ], + }); + if (isCancel(placement)) { + cancel("Cancelled."); + process.exit(0); + } + + const placementPrompt = + placement === "in-repo" + ? "Folder path for the plugin (e.g. plugins/my-feature)" + : "Directory name for the new package (e.g. appkit-plugin-my-feature)"; + const targetPath = await text({ + message: placementPrompt, + placeholder: + placement === "in-repo" + ? "plugins/my-plugin" + : "appkit-plugin-my-feature", + validate(value) { + if (!value?.trim()) return "Path is required."; + if ( + placement === "in-repo" && + (path.isAbsolute(value) || value.trim().startsWith("..")) + ) { + return "Use a relative path under the current directory (e.g. plugins/my-plugin)."; + } + return undefined; + }, + }); + if (isCancel(targetPath)) { + cancel("Cancelled."); + process.exit(0); + } + + const name = await text({ + message: "Plugin name (id)", + placeholder: "my-plugin", + validate(value) { + if (!value?.trim()) return "Name is required."; + if (!NAME_PATTERN.test(value as string)) { + return "Must be lowercase, start with a letter, and use only letters, numbers, and hyphens."; + } + return undefined; + }, + }); + if (isCancel(name)) { + cancel("Cancelled."); + process.exit(0); + } + + const displayName = await text({ + message: "Display name", + placeholder: "My Plugin", + initialValue: name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" "), + validate(value) { + if (!value?.trim()) return "Display name is required."; + return undefined; + }, + }); + if (isCancel(displayName)) { + cancel("Cancelled."); + process.exit(0); + } + + const description = await text({ + message: "Short description", + placeholder: "What does this plugin do?", + validate(value) { + if (!value?.trim()) return "Description is required."; + return undefined; + }, + }); + if (isCancel(description)) { + cancel("Cancelled."); + process.exit(0); + } + + const resourceTypes = await multiselect({ + message: "Which Databricks resources does this plugin need?", + options: RESOURCE_TYPE_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + })), + required: false, + }); + if (isCancel(resourceTypes)) { + cancel("Cancelled."); + process.exit(0); + } + + const resources: SelectedResource[] = []; + for (const type of resourceTypes as string[]) { + const required = await select({ + message: `${humanizeResourceType(type)} – required or optional?`, + options: [ + { + value: true, + label: "Required", + hint: "plugin needs it to function", + }, + { value: false, label: "Optional", hint: "enhances functionality" }, + ], + }); + if (isCancel(required)) { + cancel("Cancelled."); + process.exit(0); + } + const resourceDescription = await text({ + message: `Short description for ${humanizeResourceType(type)}`, + placeholder: required ? "Required for …" : "Optional for …", + }); + if (isCancel(resourceDescription)) { + cancel("Cancelled."); + process.exit(0); + } + resources.push({ + type, + required: required as boolean, + description: (resourceDescription as string) || "", + }); + } + + let author: string | undefined; + const askAuthor = await confirm({ + message: "Add author?", + initialValue: false, + }); + if (!isCancel(askAuthor) && askAuthor) { + const a = await text({ message: "Author name or organization" }); + if (!isCancel(a)) author = a as string; + } + + const version = await text({ + message: "Version", + placeholder: DEFAULT_VERSION, + initialValue: DEFAULT_VERSION, + }); + if (isCancel(version)) { + cancel("Cancelled."); + process.exit(0); + } + + let license: string | undefined; + const askLicense = await confirm({ + message: "Add license?", + initialValue: false, + }); + if (!isCancel(askLicense) && askLicense) { + const lic = await text({ + message: "License (e.g. MIT, Apache-2.0)", + placeholder: "MIT", + }); + if (!isCancel(lic)) license = lic as string; + } + + const answers: CreateAnswers = { + placement, + targetPath: (targetPath as string).trim(), + name: (name as string).trim(), + displayName: (displayName as string).trim(), + description: (description as string).trim(), + resources, + author, + version: (version as string).trim() || DEFAULT_VERSION, + license, + }; + + const targetDir = resolveTargetDir(process.cwd(), answers); + const dirExists = fs.existsSync(targetDir); + const hasContent = dirExists && fs.readdirSync(targetDir).length > 0; + if (hasContent) { + const overwrite = await confirm({ + message: `Directory ${answers.targetPath} already exists and is not empty. Overwrite?`, + initialValue: false, + }); + if (isCancel(overwrite) || !overwrite) { + cancel("Cancelled."); + process.exit(0); + } + } + + const proceed = await confirm({ + message: "Create plugin with these options?", + initialValue: true, + }); + if (isCancel(proceed) || !proceed) { + cancel("Cancelled."); + process.exit(0); + } + + const s = spinner(); + s.start("Writing files…"); + try { + scaffoldPlugin(targetDir, answers, { + isolated: placement === "isolated", + }); + s.stop("Files written."); + } catch (err) { + s.stop("Failed."); + throw err; + } + + const relativePath = path.relative(process.cwd(), targetDir); + const importPath = relativePath.startsWith(".") + ? relativePath + : `./${relativePath}`; + const exportName = answers.name + .split("-") + .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) + .join(""); + + outro("Plugin created successfully."); + + console.log("\nNext steps:\n"); + if (placement === "in-repo") { + console.log(` 1. Import and register in your server:`); + console.log(` import { ${exportName} } from "${importPath}";`); + console.log(` createApp({ plugins: [ ..., ${exportName}() ] });`); + console.log( + ` 2. Run \`npx appkit plugin sync --write\` to update appkit.plugins.json.\n`, + ); + } else { + console.log(` 1. cd into the new package and install dependencies:`); + console.log(` cd ${answers.targetPath} && pnpm install`); + console.log(` 2. Build: pnpm build`); + console.log( + ` 3. In your app: pnpm add ./${answers.targetPath} @databricks/appkit`, + ); + console.log( + ` 4. Import and register: import { ${exportName} } from "";\n`, + ); + } + } catch (err) { + console.error(err); + process.exit(1); + } +} + +export const pluginCreateCommand = new Command("create") + .description("Scaffold a new AppKit plugin (interactive)") + .action(runPluginCreate); diff --git a/packages/shared/src/cli/commands/plugin/create/resource-defaults.test.ts b/packages/shared/src/cli/commands/plugin/create/resource-defaults.test.ts new file mode 100644 index 00000000..2c8bfa7b --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/resource-defaults.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_FIELDS_BY_TYPE, + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + RESOURCE_TYPE_OPTIONS, + resourceKeyFromType, +} from "./resource-defaults"; + +describe("resource-defaults", () => { + describe("humanizeResourceType", () => { + it("returns the label for known types", () => { + expect(humanizeResourceType("sql_warehouse")).toBe("SQL Warehouse"); + expect(humanizeResourceType("vector_search_index")).toBe( + "Vector Search Index", + ); + expect(humanizeResourceType("secret")).toBe("Secret"); + expect(humanizeResourceType("app")).toBe("App"); + }); + + it("falls back to replacing underscores with spaces for unknown types", () => { + expect(humanizeResourceType("custom_thing")).toBe("custom thing"); + expect(humanizeResourceType("no_underscores")).toBe("no underscores"); + }); + + it("returns the type as-is when no underscores and unknown", () => { + expect(humanizeResourceType("custom")).toBe("custom"); + }); + }); + + describe("resourceKeyFromType", () => { + it("converts underscores to hyphens", () => { + expect(resourceKeyFromType("sql_warehouse")).toBe("sql-warehouse"); + expect(resourceKeyFromType("vector_search_index")).toBe( + "vector-search-index", + ); + expect(resourceKeyFromType("uc_function")).toBe("uc-function"); + }); + + it("returns type unchanged when no underscores", () => { + expect(resourceKeyFromType("secret")).toBe("secret"); + expect(resourceKeyFromType("app")).toBe("app"); + }); + }); + + describe("getDefaultFieldsForType", () => { + it("returns known fields for sql_warehouse", () => { + const fields = getDefaultFieldsForType("sql_warehouse"); + expect(fields).toEqual({ + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "SQL Warehouse ID" }, + }); + }); + + it("returns known fields for secret (multi-field)", () => { + const fields = getDefaultFieldsForType("secret"); + expect(fields).toHaveProperty("scope"); + expect(fields).toHaveProperty("key"); + expect(fields.scope.env).toBe("SECRET_SCOPE"); + expect(fields.key.env).toBe("SECRET_KEY"); + }); + + it("returns known fields for database (multi-field)", () => { + const fields = getDefaultFieldsForType("database"); + expect(fields).toHaveProperty("instance_name"); + expect(fields).toHaveProperty("database_name"); + }); + + it("generates fallback fields for unknown types", () => { + const fields = getDefaultFieldsForType("my_custom_resource"); + expect(fields).toEqual({ + id: { + env: "DATABRICKS_MY_CUSTOM_RESOURCE_ID", + description: "my custom resource ID", + }, + }); + }); + + it("generates correct env name for simple unknown type", () => { + const fields = getDefaultFieldsForType("widget"); + expect(fields.id.env).toBe("DATABRICKS_WIDGET_ID"); + }); + }); + + describe("constants coverage", () => { + it("RESOURCE_TYPE_OPTIONS covers all DEFAULT_PERMISSION_BY_TYPE keys", () => { + const optionValues = RESOURCE_TYPE_OPTIONS.map((o) => o.value); + for (const key of Object.keys(DEFAULT_PERMISSION_BY_TYPE)) { + expect(optionValues).toContain(key); + } + }); + + it("DEFAULT_FIELDS_BY_TYPE has fields for all resource types with known multi-field layouts", () => { + for (const key of Object.keys(DEFAULT_FIELDS_BY_TYPE)) { + const fields = DEFAULT_FIELDS_BY_TYPE[key]; + for (const fieldEntry of Object.values(fields)) { + expect(fieldEntry.env).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + } + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts new file mode 100644 index 00000000..4fb2b859 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts @@ -0,0 +1,144 @@ +/** + * Resource type and permission defaults for plugin scaffolding. + * Aligned with plugin-manifest.schema.json $defs. + */ + +export const MANIFEST_SCHEMA_ID = + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json"; + +export interface ResourceTypeOption { + value: string; + label: string; +} + +/** Resource types from schema resourceType enum (value, human label). */ +export const RESOURCE_TYPE_OPTIONS: ResourceTypeOption[] = [ + { value: "secret", label: "Secret" }, + { value: "job", label: "Job" }, + { value: "sql_warehouse", label: "SQL Warehouse" }, + { value: "serving_endpoint", label: "Serving Endpoint" }, + { value: "volume", label: "Volume" }, + { value: "vector_search_index", label: "Vector Search Index" }, + { value: "uc_function", label: "UC Function" }, + { value: "uc_connection", label: "UC Connection" }, + { value: "database", label: "Database" }, + { value: "genie_space", label: "Genie Space" }, + { value: "experiment", label: "Experiment" }, + { value: "app", label: "App" }, +]; + +/** All valid permissions per resource type, aligned with the schema if/then rules. */ +export const PERMISSIONS_BY_TYPE: Record = { + secret: ["READ", "WRITE", "MANAGE"], + job: ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"], + sql_warehouse: ["CAN_USE", "CAN_MANAGE"], + serving_endpoint: ["CAN_QUERY", "CAN_VIEW", "CAN_MANAGE"], + volume: ["READ_VOLUME", "WRITE_VOLUME"], + vector_search_index: ["SELECT"], + uc_function: ["EXECUTE"], + uc_connection: ["USE_CONNECTION"], + database: ["CAN_CONNECT_AND_CREATE"], + genie_space: ["CAN_VIEW", "CAN_RUN", "CAN_EDIT", "CAN_MANAGE"], + experiment: ["CAN_READ", "CAN_EDIT", "CAN_MANAGE"], + app: ["CAN_USE"], +}; + +/** Default (first) permission per resource type for scaffolding. */ +export const DEFAULT_PERMISSION_BY_TYPE: Record = + Object.fromEntries( + Object.entries(PERMISSIONS_BY_TYPE).map(([type, perms]) => [ + type, + perms[0], + ]), + ); + +/** Default fields per resource type: field key -> { env, description }. */ +export const DEFAULT_FIELDS_BY_TYPE: Record< + string, + Record +> = { + sql_warehouse: { + id: { env: "DATABRICKS_WAREHOUSE_ID", description: "SQL Warehouse ID" }, + }, + secret: { + scope: { env: "SECRET_SCOPE", description: "Secret scope name" }, + key: { env: "SECRET_KEY", description: "Secret key" }, + }, + job: { + id: { env: "DATABRICKS_JOB_ID", description: "Job ID" }, + }, + serving_endpoint: { + id: { + env: "DATABRICKS_SERVING_ENDPOINT_ID", + description: "Serving endpoint ID", + }, + }, + volume: { + name: { env: "VOLUME_NAME", description: "Volume name" }, + }, + vector_search_index: { + endpoint_name: { + env: "VECTOR_SEARCH_ENDPOINT_NAME", + description: "Vector search endpoint name", + }, + index_name: { + env: "VECTOR_SEARCH_INDEX_NAME", + description: "Vector search index name", + }, + }, + uc_function: { + name: { + env: "UC_FUNCTION_NAME", + description: "Unity Catalog function name", + }, + }, + uc_connection: { + name: { + env: "UC_CONNECTION_NAME", + description: "Unity Catalog connection name", + }, + }, + database: { + instance_name: { + env: "DATABRICKS_INSTANCE_NAME", + description: "Databricks instance name", + }, + database_name: { + env: "DATABASE_NAME", + description: "Database name", + }, + }, + genie_space: { + id: { env: "GENIE_SPACE_ID", description: "Genie Space ID" }, + }, + experiment: { + id: { env: "MLFLOW_EXPERIMENT_ID", description: "MLflow experiment ID" }, + }, + app: { + id: { env: "DATABRICKS_APP_ID", description: "Databricks App ID" }, + }, +}; + +/** Humanized alias from resource type (e.g. sql_warehouse -> "SQL Warehouse"). */ +export function humanizeResourceType(type: string): string { + const option = RESOURCE_TYPE_OPTIONS.find((o) => o.value === type); + return option ? option.label : type.replace(/_/g, " "); +} + +/** Kebab-case resource key from type (e.g. sql_warehouse -> "sql-warehouse"). */ +export function resourceKeyFromType(type: string): string { + return type.replace(/_/g, "-"); +} + +/** Get default fields for a resource type; fallback to single id field. */ +export function getDefaultFieldsForType( + type: string, +): Record { + const known = DEFAULT_FIELDS_BY_TYPE[type]; + if (known) return known; + const key = resourceKeyFromType(type); + const envName = `DATABRICKS_${key.toUpperCase().replace(/-/g, "_")}_ID`; + return { + id: { env: envName, description: `${humanizeResourceType(type)} ID` }, + }; +} diff --git a/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts new file mode 100644 index 00000000..a670e570 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts @@ -0,0 +1,225 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; +import type { CreateAnswers } from "./types"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "scaffold-test-")); +} + +function cleanDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +const BASE_ANSWERS: CreateAnswers = { + placement: "in-repo", + targetPath: "test-plugin", + name: "my-plugin", + displayName: "My Plugin", + description: "A test plugin", + resources: [], + version: "1.0.0", +}; + +describe("scaffold", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) cleanDir(dir); + tempDirs.length = 0; + }); + + describe("resolveTargetDir", () => { + it("resolves relative path against cwd", () => { + const result = resolveTargetDir("/home/user/project", { + ...BASE_ANSWERS, + targetPath: "plugins/my-plugin", + }); + expect(result).toBe( + path.resolve("/home/user/project", "plugins/my-plugin"), + ); + }); + + it("resolves absolute path as-is", () => { + const result = resolveTargetDir("/home/user/project", { + ...BASE_ANSWERS, + targetPath: "/tmp/my-plugin", + }); + expect(result).toBe("/tmp/my-plugin"); + }); + }); + + describe("scaffoldPlugin (in-repo)", () => { + it("creates core files: manifest.json, manifest.ts, plugin.ts, index.ts", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "my-plugin"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: false }); + + expect(fs.existsSync(path.join(targetDir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "manifest.ts"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "my-plugin.ts"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "index.ts"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "package.json"))).toBe(false); + }); + + it("generates valid manifest.json with correct fields", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: false }); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.name).toBe("my-plugin"); + expect(manifest.displayName).toBe("My Plugin"); + expect(manifest.description).toBe("A test plugin"); + expect(manifest.resources).toEqual({ required: [], optional: [] }); + expect(manifest.$schema).toContain("plugin-manifest.schema.json"); + }); + + it("generates plugin class with PascalCase name", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: false }); + + const pluginTs = fs.readFileSync( + path.join(targetDir, "my-plugin.ts"), + "utf-8", + ); + expect(pluginTs).toContain("class MyPlugin"); + expect(pluginTs).toContain("export const myPlugin = toPlugin"); + }); + + it("generates index.ts with correct exports", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: false }); + + const indexTs = fs.readFileSync( + path.join(targetDir, "index.ts"), + "utf-8", + ); + expect(indexTs).toContain("MyPlugin"); + expect(indexTs).toContain("myPlugin"); + expect(indexTs).toContain("manifest"); + }); + + it("includes resources in manifest when provided", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + const answers: CreateAnswers = { + ...BASE_ANSWERS, + resources: [ + { + type: "sql_warehouse", + required: true, + description: "Needed for queries", + }, + { type: "secret", required: false, description: "Optional creds" }, + ], + }; + + scaffoldPlugin(targetDir, answers, { isolated: false }); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.resources.required).toHaveLength(1); + expect(manifest.resources.optional).toHaveLength(1); + expect(manifest.resources.required[0].type).toBe("sql_warehouse"); + expect(manifest.resources.optional[0].type).toBe("secret"); + }); + + it("includes optional author, version, license", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + const answers: CreateAnswers = { + ...BASE_ANSWERS, + author: "Test Author", + version: "2.0.0", + license: "MIT", + }; + + scaffoldPlugin(targetDir, answers, { isolated: false }); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.author).toBe("Test Author"); + expect(manifest.version).toBe("2.0.0"); + expect(manifest.license).toBe("MIT"); + }); + }); + + describe("scaffoldPlugin (isolated)", () => { + it("creates package.json, tsconfig.json, and README.md in addition to core files", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "my-plugin"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: true }); + + expect(fs.existsSync(path.join(targetDir, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "tsconfig.json"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "README.md"))).toBe(true); + }); + + it("generates package.json with correct name prefix", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin(targetDir, BASE_ANSWERS, { isolated: true }); + + const pkg = JSON.parse( + fs.readFileSync(path.join(targetDir, "package.json"), "utf-8"), + ); + expect(pkg.name).toBe("appkit-plugin-my-plugin"); + expect(pkg.version).toBe("1.0.0"); + expect(pkg.type).toBe("module"); + expect(pkg.peerDependencies["@databricks/appkit"]).toBeDefined(); + }); + }); + + describe("rollback on failure", () => { + it("cleans up written files when a write fails partway through", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "failing-plugin"); + + const badAnswers: CreateAnswers = { + ...BASE_ANSWERS, + name: "test-fail", + }; + + fs.mkdirSync(targetDir, { recursive: true }); + const blockingDir = path.join(targetDir, "index.ts"); + fs.mkdirSync(blockingDir); + + expect(() => + scaffoldPlugin(targetDir, badAnswers, { isolated: false }), + ).toThrow(); + + expect(fs.existsSync(path.join(targetDir, "manifest.json"))).toBe(false); + expect(fs.existsSync(path.join(targetDir, "manifest.ts"))).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/create/scaffold.ts b/packages/shared/src/cli/commands/plugin/create/scaffold.ts new file mode 100644 index 00000000..64398c63 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.ts @@ -0,0 +1,261 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + MANIFEST_SCHEMA_ID, + resourceKeyFromType, +} from "./resource-defaults"; +import type { CreateAnswers } from "./types"; + +/** Convert kebab-name to PascalCase (e.g. my-plugin -> MyPlugin). */ +function toPascalCase(name: string): string { + return name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()) + .join(""); +} + +/** Convert kebab-name to camelCase (e.g. my-plugin -> myPlugin). */ +function toCamelCase(name: string): string { + const pascal = toPascalCase(name); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +/** Build manifest.json resources from selected resources. */ +function buildManifestResources(answers: CreateAnswers) { + const required: unknown[] = []; + const optional: unknown[] = []; + + for (const r of answers.resources) { + const permission = DEFAULT_PERMISSION_BY_TYPE[r.type] ?? "CAN_VIEW"; + const fields = getDefaultFieldsForType(r.type); + const alias = humanizeResourceType(r.type); + const resourceKey = resourceKeyFromType(r.type); + const entry = { + type: r.type, + alias, + resourceKey, + description: r.description || `Required for ${alias} functionality.`, + permission, + fields, + }; + if (r.required) { + required.push(entry); + } else { + optional.push(entry); + } + } + + return { required, optional }; +} + +/** Build full manifest object for manifest.json. */ +function buildManifest(answers: CreateAnswers): Record { + const { required, optional } = buildManifestResources(answers); + const manifest: Record = { + $schema: MANIFEST_SCHEMA_ID, + name: answers.name, + displayName: answers.displayName, + description: answers.description, + resources: { required, optional }, + }; + if (answers.author) manifest.author = answers.author; + if (answers.version) manifest.version = answers.version; + if (answers.license) manifest.license = answers.license; + return manifest; +} + +/** Resolve absolute target directory from cwd and answers. */ +export function resolveTargetDir(cwd: string, answers: CreateAnswers): string { + return path.resolve(cwd, answers.targetPath); +} + +/** Track files written during scaffolding for rollback on failure. */ +function writeTracked( + filePath: string, + content: string, + written: string[], +): void { + fs.writeFileSync(filePath, content); + written.push(filePath); +} + +/** Remove files written during a failed scaffold attempt. */ +function rollback(written: string[], targetDir: string): void { + for (const filePath of written.reverse()) { + try { + fs.unlinkSync(filePath); + } catch { + // best-effort cleanup + } + } + try { + const remaining = fs.readdirSync(targetDir); + if (remaining.length === 0) fs.rmdirSync(targetDir); + } catch { + // directory may not be empty or may have been removed already + } +} + +/** + * Scaffold plugin files into targetDir. Pure: no interactive I/O. + * Writes manifest.json, manifest.ts, {name}.ts, index.ts; for isolated also package.json, tsconfig.json, README.md. + * On failure, rolls back any files already written. + */ +export function scaffoldPlugin( + targetDir: string, + answers: CreateAnswers, + options: { isolated: boolean }, +): void { + fs.mkdirSync(targetDir, { recursive: true }); + + const written: string[] = []; + + try { + const manifest = buildManifest(answers); + const className = toPascalCase(answers.name); + const exportName = toCamelCase(answers.name); + + writeTracked( + path.join(targetDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + written, + ); + + const manifestTs = `import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PluginManifest } from "@databricks/appkit"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const manifest = JSON.parse( + readFileSync(join(__dirname, "manifest.json"), "utf-8"), +) as PluginManifest; +`; + + writeTracked(path.join(targetDir, "manifest.ts"), manifestTs, written); + + const pluginTs = `import { Plugin, toPlugin, type IAppRouter } from "@databricks/appkit"; +import { manifest } from "./manifest.js"; + +export class ${className} extends Plugin { + name = "${answers.name}"; + + static manifest = manifest; + + injectRoutes(router: IAppRouter): void { + // Add your routes here, e.g.: + // this.route(router, { + // name: "example", + // method: "get", + // path: "/", + // handler: async (_req, res) => { + // res.json({ message: "Hello from ${answers.name}" }); + // }, + // }); + } +} + +export const ${exportName} = toPlugin< + typeof ${className}, + Record, + "${answers.name}" +>(${className}, "${answers.name}"); +`; + + writeTracked(path.join(targetDir, `${answers.name}.ts`), pluginTs, written); + + const indexTs = `export { ${className}, ${exportName}, manifest } from "./${answers.name}.js"; +`; + + writeTracked(path.join(targetDir, "index.ts"), indexTs, written); + + if (options.isolated) { + const packageName = + answers.name.includes("/") || answers.name.startsWith("@") + ? answers.name + : `appkit-plugin-${answers.name}`; + + const packageJson = { + name: packageName, + version: answers.version || "1.0.0", + type: "module", + main: "./dist/index.js", + types: "./dist/index.d.ts", + files: ["dist"], + scripts: { + build: "tsc", + typecheck: "tsc --noEmit", + }, + peerDependencies: { + "@databricks/appkit": ">=0.5.0", + }, + devDependencies: { + typescript: "^5.0.0", + }, + }; + + writeTracked( + path.join(targetDir, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + written, + ); + + const tsconfigJson = { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + outDir: "dist", + rootDir: ".", + declaration: true, + strict: true, + skipLibCheck: true, + }, + include: ["*.ts"], + exclude: ["node_modules", "dist"], + }; + + writeTracked( + path.join(targetDir, "tsconfig.json"), + `${JSON.stringify(tsconfigJson, null, 2)}\n`, + written, + ); + + const readme = `# ${answers.displayName} + +${answers.description} + +## Installation + +\`\`\`bash +pnpm add ${packageName} @databricks/appkit +\`\`\` + +## Usage + +Register the plugin in your AppKit app: + +\`\`\`ts +import { createApp } from "@databricks/appkit"; +import { ${exportName} } from "${packageName}"; + +createApp({ + plugins: [ + ${exportName}(), + // ... other plugins + ], +}).then((app) => { /* ... */ }); +\`\`\` +`; + + writeTracked(path.join(targetDir, "README.md"), readme, written); + } + } catch (err) { + rollback(written, targetDir); + throw err; + } +} diff --git a/packages/shared/src/cli/commands/plugin/create/types.ts b/packages/shared/src/cli/commands/plugin/create/types.ts new file mode 100644 index 00000000..33ea3574 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/types.ts @@ -0,0 +1,26 @@ +/** + * Types for plugin create CLI answers and scaffold input. + */ + +export type Placement = "in-repo" | "isolated"; + +/** A resource selected by the user (type + required/optional + description). */ +export interface SelectedResource { + type: string; + required: boolean; + description: string; +} + +/** Collected answers from prompts. */ +export interface CreateAnswers { + placement: Placement; + /** For in-repo: folder path (e.g. plugins/my-plugin). For isolated: directory name (e.g. appkit-plugin-my-feature). */ + targetPath: string; + name: string; + displayName: string; + description: string; + resources: SelectedResource[]; + author?: string; + version: string; + license?: string; +} diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts new file mode 100644 index 00000000..04b8cba9 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -0,0 +1,23 @@ +import { Command } from "commander"; +import { pluginAddResourceCommand } from "./add-resource/add-resource"; +import { pluginCreateCommand } from "./create/create"; +import { pluginListCommand } from "./list/list"; +import { pluginsSyncCommand } from "./sync/sync"; +import { pluginValidateCommand } from "./validate/validate"; + +/** + * Parent command for plugin management operations. + * Subcommands: + * - sync: Aggregate plugin manifests into appkit.plugins.json + * - create: Scaffold a new plugin (interactive) + * - validate: Validate manifest(s) against the JSON schema + * - list: List plugins from appkit.plugins.json or a directory + * - add-resource: Add a resource requirement to a plugin manifest (interactive) + */ +export const pluginCommand = new Command("plugin") + .description("Plugin management commands") + .addCommand(pluginsSyncCommand) + .addCommand(pluginCreateCommand) + .addCommand(pluginValidateCommand) + .addCommand(pluginListCommand) + .addCommand(pluginAddResourceCommand); diff --git a/packages/shared/src/cli/commands/plugin/list/list.test.ts b/packages/shared/src/cli/commands/plugin/list/list.test.ts new file mode 100644 index 00000000..b6a37f60 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -0,0 +1,175 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listFromDirectory, + listFromManifestFile, + type PluginRow, +} from "./list"; + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); +} + +function cleanDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +const TEMPLATE_MANIFEST_JSON = { + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins: { + server: { + name: "server", + displayName: "Server Plugin", + package: "@databricks/appkit", + resources: { required: [], optional: [] }, + }, + analytics: { + name: "analytics", + displayName: "Analytics Plugin", + package: "@databricks/appkit", + resources: { + required: [{ type: "sql_warehouse" }], + optional: [], + }, + }, + }, +}; + +const PLUGIN_MANIFEST_JSON = { + $schema: + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + name: "my-feature", + displayName: "My Feature", + description: "A test plugin", + resources: { required: [], optional: [] }, +}; + +describe("list", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) cleanDir(dir); + tempDirs.length = 0; + }); + + describe("listFromManifestFile", () => { + it("returns plugin rows from a template manifest file", () => { + const tmp = makeTempDir("list-manifest"); + tempDirs.push(tmp); + const manifestPath = path.join(tmp, "appkit.plugins.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify(TEMPLATE_MANIFEST_JSON, null, 2), + ); + + const rows = listFromManifestFile(manifestPath); + + expect(rows).toHaveLength(2); + const byName = (r: PluginRow) => r.name; + expect(rows.map(byName).sort()).toEqual(["analytics", "server"]); + const server = rows.find((r) => r.name === "server"); + expect(server?.displayName).toBe("Server Plugin"); + expect(server?.package).toBe("@databricks/appkit"); + expect(server?.required).toBe(0); + expect(server?.optional).toBe(0); + const analytics = rows.find((r) => r.name === "analytics"); + expect(analytics?.required).toBe(1); + expect(analytics?.optional).toBe(0); + }); + + it("returns empty array when plugins object is empty", () => { + const tmp = makeTempDir("list-manifest-empty"); + tempDirs.push(tmp); + const manifestPath = path.join(tmp, "appkit.plugins.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins: {}, + }), + ); + + const rows = listFromManifestFile(manifestPath); + expect(rows).toEqual([]); + }); + + it("throws when file does not exist", () => { + expect(() => + listFromManifestFile("/nonexistent/appkit.plugins.json"), + ).toThrow(/Failed to read manifest file/); + }); + + it("throws when file is invalid JSON", () => { + const tmp = makeTempDir("list-manifest-bad"); + tempDirs.push(tmp); + const manifestPath = path.join(tmp, "bad.json"); + fs.writeFileSync(manifestPath, "not json {"); + + expect(() => listFromManifestFile(manifestPath)).toThrow( + /Failed to parse manifest file/, + ); + }); + }); + + describe("listFromDirectory", () => { + it("returns plugin rows from subdirectories with manifest.json", () => { + const tmp = makeTempDir("list-dir"); + tempDirs.push(tmp); + const pluginDir = path.join(tmp, "my-feature"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "manifest.json"), + JSON.stringify(PLUGIN_MANIFEST_JSON, null, 2), + ); + + const rows = listFromDirectory(tmp, path.dirname(tmp)); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe("my-feature"); + expect(rows[0].displayName).toBe("My Feature"); + expect(rows[0].package).toContain("my-feature"); + expect(rows[0].required).toBe(0); + expect(rows[0].optional).toBe(0); + }); + + it("returns empty array when directory does not exist", () => { + const rows = listFromDirectory("/nonexistent/dir", "/"); + expect(rows).toEqual([]); + }); + + it("returns empty array when directory has no plugin subdirs with manifest.json", () => { + const tmp = makeTempDir("list-dir-empty"); + tempDirs.push(tmp); + fs.mkdirSync(path.join(tmp, "empty-subdir"), { recursive: true }); + + const rows = listFromDirectory(tmp, path.dirname(tmp)); + expect(rows).toEqual([]); + }); + + it("skips subdirs without manifest.json", () => { + const tmp = makeTempDir("list-dir-skip"); + tempDirs.push(tmp); + const withManifest = path.join(tmp, "with-manifest"); + fs.mkdirSync(withManifest, { recursive: true }); + fs.writeFileSync( + path.join(withManifest, "manifest.json"), + JSON.stringify(PLUGIN_MANIFEST_JSON, null, 2), + ); + fs.mkdirSync(path.join(tmp, "no-manifest"), { recursive: true }); + + const rows = listFromDirectory(tmp, path.dirname(tmp)); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe("my-feature"); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts new file mode 100644 index 00000000..1da5e8af --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -0,0 +1,175 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { validateManifest } from "../validate/validate-manifest"; + +export interface PluginRow { + name: string; + displayName: string; + package: string; + required: number; + optional: number; +} + +export function listFromManifestFile(manifestPath: string): PluginRow[] { + let raw: string; + try { + raw = fs.readFileSync(manifestPath, "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read manifest file ${manifestPath}: ${msg}`); + } + + let data: { + plugins?: Record< + string, + { + name: string; + displayName: string; + package: string; + resources: { required: unknown[]; optional: unknown[] }; + } + >; + }; + try { + data = JSON.parse(raw) as typeof data; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse manifest file ${manifestPath}: ${msg}`); + } + + const plugins = data.plugins ?? {}; + return Object.values(plugins).map((p) => ({ + name: p.name, + displayName: p.displayName ?? p.name, + package: p.package ?? "", + required: Array.isArray(p.resources?.required) + ? p.resources.required.length + : 0, + optional: Array.isArray(p.resources?.optional) + ? p.resources.optional.length + : 0, + })); +} + +export function listFromDirectory(dirPath: string, cwd: string): PluginRow[] { + const resolved = path.resolve(cwd, dirPath); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { + return []; + } + const entries = fs.readdirSync(resolved, { withFileTypes: true }); + const rows: PluginRow[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(resolved, entry.name, "manifest.json"); + if (!fs.existsSync(manifestPath)) continue; + try { + const raw = fs.readFileSync(manifestPath, "utf-8"); + const obj = JSON.parse(raw); + const result = validateManifest(obj); + const manifest = result.valid ? result.manifest : null; + if (!manifest) continue; + const relPath = path.relative(cwd, path.dirname(manifestPath)); + const packagePath = relPath.startsWith(".") ? relPath : `./${relPath}`; + rows.push({ + name: manifest.name, + displayName: manifest.displayName ?? manifest.name, + package: packagePath, + required: Array.isArray(manifest.resources?.required) + ? manifest.resources.required.length + : 0, + optional: Array.isArray(manifest.resources?.optional) + ? manifest.resources.optional.length + : 0, + }); + } catch { + // skip invalid manifests + } + } + return rows; +} + +function printTable(rows: PluginRow[]): void { + if (rows.length === 0) { + console.log("No plugins found."); + return; + } + const maxName = Math.max(4, ...rows.map((r) => r.name.length)); + const maxDisplay = Math.max(10, ...rows.map((r) => r.displayName.length)); + const maxPkg = Math.max(7, ...rows.map((r) => r.package.length)); + const header = [ + "NAME".padEnd(maxName), + "DISPLAY NAME".padEnd(maxDisplay), + "PACKAGE / PATH".padEnd(maxPkg), + "REQ", + "OPT", + ].join(" "); + console.log(header); + console.log("-".repeat(header.length)); + for (const r of rows) { + console.log( + [ + r.name.padEnd(maxName), + r.displayName.padEnd(maxDisplay), + r.package.padEnd(maxPkg), + String(r.required).padStart(3), + String(r.optional).padStart(3), + ].join(" "), + ); + } +} + +function runPluginList(options: { + manifest?: string; + dir?: string; + json?: boolean; +}): void { + const cwd = process.cwd(); + let rows: PluginRow[]; + + if (options.dir !== undefined) { + rows = listFromDirectory(options.dir, cwd); + if (rows.length === 0 && options.dir) { + console.error( + `No plugin directories with manifest.json found in ${options.dir}`, + ); + process.exit(1); + } + } else { + const manifestPath = path.resolve( + cwd, + options.manifest ?? "appkit.plugins.json", + ); + if (!fs.existsSync(manifestPath)) { + console.error(`Manifest not found: ${manifestPath}`); + process.exit(1); + } + try { + rows = listFromManifestFile(manifestPath); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } + + if (options.json) { + console.log(JSON.stringify(rows, null, 2)); + } else { + printTable(rows); + } +} + +export const pluginListCommand = new Command("list") + .description("List plugins from appkit.plugins.json or a directory") + .option( + "-m, --manifest ", + "Path to appkit.plugins.json", + "appkit.plugins.json", + ) + .option( + "-d, --dir ", + "Scan directory for plugin folders (each with manifest.json)", + ) + .option("--json", "Output as JSON") + .action(runPluginList); diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts new file mode 100644 index 00000000..420a09b3 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -0,0 +1,42 @@ +/** + * Shared types for plugin manifests used across CLI commands. + * Single source of truth for manifest structure — avoids duplicate + * definitions in sync, validate, list, and add-resource commands. + */ + +export interface ResourceFieldEntry { + env: string; + description?: string; +} + +export interface ResourceRequirement { + type: string; + alias: string; + resourceKey: string; + description: string; + permission: string; + fields: Record; +} + +export interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: ResourceRequirement[]; + optional: ResourceRequirement[]; + }; + config?: { schema: unknown }; +} + +export interface TemplatePlugin extends Omit { + package: string; + /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ + requiredByTemplate?: boolean; +} + +export interface TemplatePluginsManifest { + $schema: string; + version: string; + plugins: Record; +} diff --git a/packages/shared/src/cli/commands/plugins-sync.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts similarity index 97% rename from packages/shared/src/cli/commands/plugins-sync.test.ts rename to packages/shared/src/cli/commands/plugin/sync/sync.test.ts index 592ff163..737dc246 100644 --- a/packages/shared/src/cli/commands/plugins-sync.test.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts @@ -1,13 +1,9 @@ import path from "node:path"; import { Lang, parse } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; -import { - isWithinDirectory, - parseImports, - parsePluginUsages, -} from "./plugins-sync"; +import { isWithinDirectory, parseImports, parsePluginUsages } from "./sync"; -describe("plugins-sync", () => { +describe("plugin sync", () => { describe("isWithinDirectory", () => { it("returns true when filePath equals boundary", () => { const dir = path.resolve("/project/root"); diff --git a/packages/shared/src/cli/commands/plugins-sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts similarity index 74% rename from packages/shared/src/cli/commands/plugins-sync.ts rename to packages/shared/src/cli/commands/plugin/sync/sync.ts index ef4cbc2e..5d00b656 100644 --- a/packages/shared/src/cli/commands/plugins-sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -1,74 +1,16 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { Lang, parse, type SgNode } from "@ast-grep/napi"; -import Ajv, { type ErrorObject } from "ajv"; -import addFormats from "ajv-formats"; import { Command } from "commander"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Resolve to package schemas: from dist/cli/commands -> dist/schemas, from src/cli/commands -> shared/schemas -const PLUGIN_MANIFEST_SCHEMA_PATH = path.join( - __dirname, - "..", - "..", - "..", - "schemas", - "plugin-manifest.schema.json", -); - -/** - * Field entry in a resource requirement (env var + optional description) - */ -interface ResourceFieldEntry { - env: string; - description?: string; -} - -/** - * Resource requirement as defined in plugin manifests. - * Uses fields (single key e.g. id, or multiple e.g. instance_name/database_name, scope/key). - */ -interface ResourceRequirement { - type: string; - alias: string; - resourceKey: string; - description: string; - permission: string; - fields: Record; -} - -/** - * Plugin manifest structure (from SDK plugin manifest.json files) - */ -interface PluginManifest { - name: string; - displayName: string; - description: string; - resources: { - required: ResourceRequirement[]; - optional: ResourceRequirement[]; - }; - config?: { schema: unknown }; -} - -/** - * Plugin entry in the template manifest (includes package source) - */ -interface TemplatePlugin extends Omit { - package: string; - /** When true, this plugin is required by the template and cannot be deselected during CLI init. */ - requiredByTemplate?: boolean; -} - -/** - * Template plugins manifest structure - */ -interface TemplatePluginsManifest { - $schema: string; - version: string; - plugins: Record; -} +import type { + PluginManifest, + TemplatePlugin, + TemplatePluginsManifest, +} from "../manifest-types"; +import { + formatValidationErrors, + validateManifest, +} from "../validate/validate-manifest"; /** * Checks whether a resolved file path is within a given directory boundary. @@ -88,81 +30,21 @@ function isWithinDirectory(filePath: string, boundary: string): boolean { ); } -let pluginManifestValidator: ReturnType | null = null; - -/** - * Loads and compiles the plugin-manifest JSON schema (cached). - * Returns the compiled validate function or null if the schema cannot be loaded. - */ -function getPluginManifestValidator(): ReturnType | null { - if (pluginManifestValidator) return pluginManifestValidator; - try { - const schemaRaw = fs.readFileSync(PLUGIN_MANIFEST_SCHEMA_PATH, "utf-8"); - const schema = JSON.parse(schemaRaw) as object; - const ajv = new Ajv({ allErrors: true, strict: false }); - addFormats(ajv); - pluginManifestValidator = ajv.compile(schema); - return pluginManifestValidator; - } catch (err) { - console.warn( - "Warning: Could not load plugin-manifest schema for validation:", - err instanceof Error ? err.message : err, - ); - return null; - } -} - /** * Validates a parsed JSON object against the plugin-manifest JSON schema. * Returns the manifest if valid, or null and logs schema errors. - * - * @param obj - The parsed JSON object to validate - * @param sourcePath - Path to the manifest file (for warning messages) - * @returns A valid PluginManifest or null */ function validateManifestWithSchema( obj: unknown, sourcePath: string, ): PluginManifest | null { - if (!obj || typeof obj !== "object") { - console.warn(`Warning: Manifest at ${sourcePath} is not a valid object`); - return null; - } - - const validate = getPluginManifestValidator(); - if (!validate) { - // Schema not available (e.g. dev without build); fall back to basic shape check - const m = obj as Record; - if ( - typeof m.name === "string" && - m.name.length > 0 && - typeof m.displayName === "string" && - m.displayName.length > 0 && - typeof m.description === "string" && - m.description.length > 0 && - m.resources && - typeof m.resources === "object" && - Array.isArray((m.resources as { required?: unknown }).required) - ) { - return obj as PluginManifest; - } - console.warn(`Warning: Manifest at ${sourcePath} has invalid structure`); - return null; + const result = validateManifest(obj); + if (result.valid && result.manifest) return result.manifest; + if (result.errors?.length) { + console.warn( + `Warning: Manifest at ${sourcePath} failed schema validation:\n${formatValidationErrors(result.errors, obj)}`, + ); } - - const valid = validate(obj); - if (valid) return obj as PluginManifest; - - const errors: ErrorObject[] = validate.errors ?? []; - const message = errors - .map( - (e: ErrorObject) => - ` ${e.instancePath || "/"} ${e.message}${e.params ? ` (${JSON.stringify(e.params)})` : ""}`, - ) - .join("\n"); - console.warn( - `Warning: Manifest at ${sourcePath} failed schema validation:\n${message}`, - ); return null; } @@ -176,7 +58,7 @@ const KNOWN_PLUGIN_PACKAGES = ["@databricks/appkit"]; * Candidate paths for the server entry file, relative to cwd. * Checked in order; the first that exists is used. */ -const SERVER_FILE_CANDIDATES = ["server/server.ts"]; +const SERVER_FILE_CANDIDATES = ["server/server.ts", "server/index.ts"]; /** * Find the server entry file by checking candidate paths in order. @@ -484,12 +366,100 @@ function scanForPlugins( } /** - * Run the plugins sync command. + * Scan a directory for plugin manifests in direct subdirectories. + * Each subdirectory is expected to contain a manifest.json file. + * Used with --plugins-dir to discover plugins from source instead of node_modules. + * + * @param dir - Absolute path to the directory containing plugin subdirectories + * @param packageName - Package name to assign to discovered plugins + * @returns Map of plugin name to template plugin entry + */ +function scanPluginsDir( + dir: string, + packageName: string, +): TemplatePluginsManifest["plugins"] { + const plugins: TemplatePluginsManifest["plugins"] = {}; + + if (!fs.existsSync(dir)) return plugins; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const manifestPath = path.join(dir, entry.name, "manifest.json"); + if (!fs.existsSync(manifestPath)) continue; + + try { + const content = fs.readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(content); + const manifest = validateManifestWithSchema(parsed, manifestPath); + if (manifest) { + plugins[manifest.name] = { + name: manifest.name, + displayName: manifest.displayName, + description: manifest.description, + package: packageName, + resources: manifest.resources, + }; + } + } catch (error) { + console.warn( + `Warning: Failed to parse manifest at ${manifestPath}:`, + error instanceof Error ? error.message : error, + ); + } + } + + return plugins; +} + +/** + * Write (or preview) the template plugins manifest to disk. + */ +function writeManifest( + outputPath: string, + { plugins }: { plugins: TemplatePluginsManifest["plugins"] }, + options: { write?: boolean; silent?: boolean }, +) { + const templateManifest: TemplatePluginsManifest = { + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins, + }; + + if (options.write) { + fs.writeFileSync( + outputPath, + `${JSON.stringify(templateManifest, null, 2)}\n`, + ); + if (!options.silent) { + console.log(`\n✓ Wrote ${outputPath}`); + } + } else if (!options.silent) { + console.log("\nTo write the manifest, run:"); + console.log(" npx appkit plugin sync --write\n"); + console.log("Preview:"); + console.log("─".repeat(60)); + console.log(JSON.stringify(templateManifest, null, 2)); + console.log("─".repeat(60)); + } +} + +/** + * Run the plugin sync command. * Parses the server entry file to discover which packages to scan for plugin * manifests, then marks plugins that are actually used in the `plugins: [...]` * array as requiredByTemplate. */ -function runPluginsSync(options: { write?: boolean; output?: string }) { +function runPluginsSync(options: { + write?: boolean; + output?: string; + silent?: boolean; + requirePlugins?: string; + pluginsDir?: string; + packageName?: string; +}) { const cwd = process.cwd(); const outputPath = path.resolve(cwd, options.output || "appkit.plugins.json"); @@ -501,7 +471,9 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { process.exit(1); } - console.log("Scanning for AppKit plugins...\n"); + if (!options.silent) { + console.log("Scanning for AppKit plugins...\n"); + } // Step 1: Parse server file to discover imports and plugin usages const serverFile = findServerFile(cwd); @@ -509,8 +481,10 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { let pluginUsages = new Set(); if (serverFile) { - const relativePath = path.relative(cwd, serverFile); - console.log(`Server entry file: ${relativePath}`); + if (!options.silent) { + const relativePath = path.relative(cwd, serverFile); + console.log(`Server entry file: ${relativePath}`); + } const content = fs.readFileSync(serverFile, "utf-8"); const lang = serverFile.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript; @@ -519,7 +493,7 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { serverImports = parseImports(root); pluginUsages = parsePluginUsages(root); - } else { + } else if (!options.silent) { console.log( "No server entry file found. Checked:", SERVER_FILE_CANDIDATES.join(", "), @@ -534,12 +508,23 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { (i) => i.source.startsWith(".") || i.source.startsWith("/"), ); - // Step 3: Scan npm packages for plugin manifests - const npmPackages = new Set([ - ...KNOWN_PLUGIN_PACKAGES, - ...npmImports.map((i) => i.source), - ]); - const plugins = scanForPlugins(cwd, npmPackages); + // Step 3: Scan for plugin manifests (--plugins-dir or node_modules) + const plugins: TemplatePluginsManifest["plugins"] = {}; + + if (options.pluginsDir) { + const resolvedDir = path.resolve(cwd, options.pluginsDir); + const pkgName = options.packageName ?? "@databricks/appkit"; + if (!options.silent) { + console.log(`Scanning plugins directory: ${options.pluginsDir}`); + } + Object.assign(plugins, scanPluginsDir(resolvedDir, pkgName)); + } else { + const npmPackages = new Set([ + ...KNOWN_PLUGIN_PACKAGES, + ...npmImports.map((i) => i.source), + ]); + Object.assign(plugins, scanForPlugins(cwd, npmPackages)); + } // Step 4: Discover local plugin manifests from relative imports if (serverFile && localImports.length > 0) { @@ -551,10 +536,15 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { const pluginCount = Object.keys(plugins).length; if (pluginCount === 0) { + if (options.silent) { + writeManifest(outputPath, { plugins: {} }, options); + return; + } console.log("No plugins found."); - console.log("\nMake sure you have plugin packages installed:"); - for (const pkg of npmPackages) { - console.log(` - ${pkg}`); + if (options.pluginsDir) { + console.log(`\nNo manifest.json files found in: ${options.pluginsDir}`); + } else { + console.log("\nMake sure you have plugin packages installed."); } process.exit(1); } @@ -592,39 +582,38 @@ function runPluginsSync(options: { write?: boolean; output?: string }) { } } - console.log(`\nFound ${pluginCount} plugin(s):`); - for (const [name, manifest] of Object.entries(plugins)) { - const resourceCount = - manifest.resources.required.length + manifest.resources.optional.length; - const resourceInfo = - resourceCount > 0 ? ` [${resourceCount} resource(s)]` : ""; - const mandatoryTag = manifest.requiredByTemplate ? " (mandatory)" : ""; - console.log( - ` ${manifest.requiredByTemplate ? "●" : "○"} ${manifest.displayName} (${name}) from ${manifest.package}${resourceInfo}${mandatoryTag}`, - ); + // Step 6: Apply explicit --require-plugins overrides + if (options.requirePlugins) { + const explicitNames = options.requirePlugins + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + for (const name of explicitNames) { + if (plugins[name]) { + plugins[name].requiredByTemplate = true; + } else if (!options.silent) { + console.warn( + `Warning: --require-plugins referenced "${name}" but no such plugin was discovered`, + ); + } + } } - const templateManifest: TemplatePluginsManifest = { - $schema: - "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", - plugins, - }; - - if (options.write) { - fs.writeFileSync( - outputPath, - `${JSON.stringify(templateManifest, null, 2)}\n`, - ); - console.log(`\n✓ Wrote ${outputPath}`); - } else { - console.log("\nTo write the manifest, run:"); - console.log(" npx appkit plugins sync --write\n"); - console.log("Preview:"); - console.log("─".repeat(60)); - console.log(JSON.stringify(templateManifest, null, 2)); - console.log("─".repeat(60)); + if (!options.silent) { + console.log(`\nFound ${pluginCount} plugin(s):`); + for (const [name, manifest] of Object.entries(plugins)) { + const resourceCount = + manifest.resources.required.length + manifest.resources.optional.length; + const resourceInfo = + resourceCount > 0 ? ` [${resourceCount} resource(s)]` : ""; + const mandatoryTag = manifest.requiredByTemplate ? " (mandatory)" : ""; + console.log( + ` ${manifest.requiredByTemplate ? "●" : "○"} ${manifest.displayName} (${name}) from ${manifest.package}${resourceInfo}${mandatoryTag}`, + ); + } } + + writeManifest(outputPath, { plugins }, options); } /** Exported for testing: path boundary check, AST parsing. */ @@ -639,4 +628,20 @@ export const pluginsSyncCommand = new Command("sync") "-o, --output ", "Output file path (default: ./appkit.plugins.json)", ) + .option( + "-s, --silent", + "Suppress output and never exit with error (for use in predev/prebuild hooks)", + ) + .option( + "--require-plugins ", + "Comma-separated plugin names to mark as requiredByTemplate (e.g. server,analytics)", + ) + .option( + "--plugins-dir ", + "Scan this directory for plugin subdirectories with manifest.json (instead of node_modules)", + ) + .option( + "--package-name ", + "Package name to assign to plugins found via --plugins-dir (default: @databricks/appkit)", + ) .action(runPluginsSync); diff --git a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts new file mode 100644 index 00000000..63dd622d --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.test.ts @@ -0,0 +1,391 @@ +import type { ErrorObject } from "ajv"; +import { describe, expect, it } from "vitest"; +import { + detectSchemaType, + formatValidationErrors, + validateManifest, + validateTemplateManifest, +} from "./validate-manifest"; + +const VALID_MANIFEST = { + $schema: + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, +}; + +const VALID_MANIFEST_WITH_RESOURCE = { + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "Required for queries", + permission: "CAN_USE", + fields: { + id: { + env: "DATABRICKS_WAREHOUSE_ID", + description: "SQL Warehouse ID", + }, + }, + }, + ], + optional: [], + }, +}; + +describe("validate-manifest", () => { + describe("detectSchemaType", () => { + it('returns "plugin-manifest" for plugin manifest $schema', () => { + expect( + detectSchemaType({ + $schema: + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + }), + ).toBe("plugin-manifest"); + }); + + it('returns "template-plugins" for template $schema', () => { + expect( + detectSchemaType({ + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + }), + ).toBe("template-plugins"); + }); + + it('returns "unknown" for missing $schema', () => { + expect(detectSchemaType({})).toBe("unknown"); + expect(detectSchemaType({ name: "test" })).toBe("unknown"); + }); + + it('returns "unknown" for unrecognized $schema', () => { + expect( + detectSchemaType({ $schema: "https://example.com/schema.json" }), + ).toBe("unknown"); + }); + + it('returns "unknown" for non-object inputs', () => { + expect(detectSchemaType(null)).toBe("unknown"); + expect(detectSchemaType(undefined)).toBe("unknown"); + expect(detectSchemaType("string")).toBe("unknown"); + expect(detectSchemaType(42)).toBe("unknown"); + }); + }); + + describe("validateManifest", () => { + it("validates a minimal correct manifest", () => { + const result = validateManifest(VALID_MANIFEST); + expect(result.valid).toBe(true); + expect(result.manifest).toBeDefined(); + expect(result.manifest?.name).toBe("test-plugin"); + }); + + it("validates a manifest with resources", () => { + const result = validateManifest(VALID_MANIFEST_WITH_RESOURCE); + expect(result.valid).toBe(true); + expect(result.manifest?.resources.required).toHaveLength(1); + }); + + it("rejects non-object input", () => { + expect(validateManifest(null).valid).toBe(false); + expect(validateManifest("string").valid).toBe(false); + expect(validateManifest(42).valid).toBe(false); + }); + + it("rejects manifest with missing required fields", () => { + const result = validateManifest({ name: "test" }); + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect((result.errors ?? []).length).toBeGreaterThan(0); + }); + + it("rejects manifest with invalid name pattern", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + name: "Invalid-Name", + }); + expect(result.valid).toBe(false); + }); + + it("rejects manifest with invalid resource type", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "invalid_type", + alias: "Invalid", + resourceKey: "invalid", + description: "test", + permission: "CAN_VIEW", + fields: { id: { env: "TEST_ID" } }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(false); + }); + + it("rejects manifest with invalid permission for resource type", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "Required for queries", + permission: "INVALID_PERM", + fields: { + id: { env: "DATABRICKS_WAREHOUSE_ID" }, + }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(false); + }); + + it("validates correct type-specific permissions", () => { + const testCases = [ + { type: "secret", permission: "READ" }, + { type: "job", permission: "CAN_VIEW" }, + { type: "sql_warehouse", permission: "CAN_USE" }, + { type: "serving_endpoint", permission: "CAN_QUERY" }, + { type: "volume", permission: "READ_VOLUME" }, + { type: "vector_search_index", permission: "SELECT" }, + { type: "uc_function", permission: "EXECUTE" }, + { type: "uc_connection", permission: "USE_CONNECTION" }, + { type: "database", permission: "CAN_CONNECT_AND_CREATE" }, + { type: "genie_space", permission: "CAN_VIEW" }, + { type: "experiment", permission: "CAN_READ" }, + { type: "app", permission: "CAN_USE" }, + ]; + + for (const { type, permission } of testCases) { + const manifest = { + ...VALID_MANIFEST, + resources: { + required: [ + { + type, + alias: "Test", + resourceKey: type.replace(/_/g, "-"), + description: "test", + permission, + fields: { id: { env: "TEST_ID" } }, + }, + ], + optional: [], + }, + }; + const result = validateManifest(manifest); + expect(result.valid).toBe(true); + } + }); + + it("rejects cross-type permissions (e.g. secret permission on sql_warehouse)", () => { + const result = validateManifest({ + ...VALID_MANIFEST, + resources: { + required: [ + { + type: "sql_warehouse", + alias: "SQL Warehouse", + resourceKey: "sql-warehouse", + description: "test", + permission: "READ", + fields: { id: { env: "WAREHOUSE_ID" } }, + }, + ], + optional: [], + }, + }); + expect(result.valid).toBe(false); + }); + }); + + describe("validateTemplateManifest", () => { + it("validates a minimal correct template manifest", () => { + const result = validateTemplateManifest({ + $schema: + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", + version: "1.0", + plugins: {}, + }); + expect(result.valid).toBe(true); + }); + + it("rejects non-object input", () => { + expect(validateTemplateManifest(null).valid).toBe(false); + expect(validateTemplateManifest("string").valid).toBe(false); + }); + }); + + describe("formatValidationErrors", () => { + it("formats a required-property error", () => { + const errors: ErrorObject[] = [ + { + keyword: "required", + instancePath: "", + schemaPath: "#/required", + params: { missingProperty: "name" }, + message: "must have required property 'name'", + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toContain('missing required property "name"'); + }); + + it("formats an enum error with actual value", () => { + const errors: ErrorObject[] = [ + { + keyword: "enum", + instancePath: "/resources/required/0/permission", + schemaPath: "#/$defs/secretPermission/enum", + params: { allowedValues: ["MANAGE", "READ", "WRITE"] }, + message: "must be equal to one of the allowed values", + }, + ]; + const obj = { + resources: { + required: [{ permission: "INVALID" }], + }, + }; + const output = formatValidationErrors(errors, obj); + expect(output).toContain("resources.required[0].permission"); + expect(output).toContain('(got "INVALID")'); + expect(output).toContain("MANAGE, READ, WRITE"); + }); + + it("formats a pattern error with actual value", () => { + const errors: ErrorObject[] = [ + { + keyword: "pattern", + instancePath: "/name", + schemaPath: "#/properties/name/pattern", + params: { pattern: "^[a-z][a-z0-9-]*$" }, + message: 'must match pattern "^[a-z][a-z0-9-]*$"', + }, + ]; + const obj = { name: "INVALID" }; + const output = formatValidationErrors(errors, obj); + expect(output).toContain("name"); + expect(output).toContain("does not match expected pattern"); + expect(output).toContain('(got "INVALID")'); + }); + + it("formats a type error", () => { + const errors: ErrorObject[] = [ + { + keyword: "type", + instancePath: "/name", + schemaPath: "#/properties/name/type", + params: { type: "string" }, + message: "must be string", + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toContain('expected type "string"'); + }); + + it("formats a minLength error", () => { + const errors: ErrorObject[] = [ + { + keyword: "minLength", + instancePath: "/displayName", + schemaPath: "#/properties/displayName/minLength", + params: { limit: 1 }, + message: "must NOT have fewer than 1 characters", + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toContain("must not be empty"); + }); + + it("formats an additionalProperties error", () => { + const errors: ErrorObject[] = [ + { + keyword: "additionalProperties", + instancePath: "", + schemaPath: "#/additionalProperties", + params: { additionalProperty: "foo" }, + message: "must NOT have additional properties", + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toContain('unknown property "foo"'); + }); + + it("collapses anyOf with enum sub-errors", () => { + const errors: ErrorObject[] = [ + { + keyword: "enum", + instancePath: "/perm", + schemaPath: "#/$defs/a/enum", + params: { allowedValues: ["A", "B"] }, + message: "must be equal to one of the allowed values", + }, + { + keyword: "enum", + instancePath: "/perm", + schemaPath: "#/$defs/b/enum", + params: { allowedValues: ["C", "D"] }, + message: "must be equal to one of the allowed values", + }, + { + keyword: "anyOf", + instancePath: "/perm", + schemaPath: "#/anyOf", + params: {}, + message: "must match a schema in anyOf", + }, + ]; + const obj = { perm: "X" }; + const output = formatValidationErrors(errors, obj); + expect(output).toContain('invalid value (got "X")'); + expect(output).toContain("A, B, C, D"); + const lines = output.split("\n"); + expect(lines.length).toBe(2); + }); + + it("skips if-keyword errors", () => { + const errors: ErrorObject[] = [ + { + keyword: "if", + instancePath: "", + schemaPath: "#/allOf/0/if", + params: { failingKeyword: "if" }, + message: 'must match "if" schema', + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toBe(""); + }); + + it("handles root-level errors with empty instancePath", () => { + const errors: ErrorObject[] = [ + { + keyword: "required", + instancePath: "", + schemaPath: "#/required", + params: { missingProperty: "name" }, + message: "must have required property 'name'", + }, + ]; + const output = formatValidationErrors(errors); + expect(output).toContain('missing required property "name"'); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts new file mode 100644 index 00000000..b0284b76 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/validate/validate-manifest.ts @@ -0,0 +1,305 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import type { PluginManifest } from "../manifest-types"; + +export type { PluginManifest }; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCHEMAS_DIR = path.join(__dirname, "..", "..", "..", "..", "schemas"); +const PLUGIN_MANIFEST_SCHEMA_PATH = path.join( + SCHEMAS_DIR, + "plugin-manifest.schema.json", +); +const TEMPLATE_PLUGINS_SCHEMA_PATH = path.join( + SCHEMAS_DIR, + "template-plugins.schema.json", +); + +export type SchemaType = "plugin-manifest" | "template-plugins" | "unknown"; + +const SCHEMA_ID_MAP: Record = { + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json": + "plugin-manifest", + "https://databricks.github.io/appkit/schemas/template-plugins.schema.json": + "template-plugins", +}; + +/** + * Detect which schema type a parsed JSON object targets based on its $schema field. + * Returns "unknown" when the field is missing or unrecognized. + */ +export function detectSchemaType(obj: unknown): SchemaType { + if (!obj || typeof obj !== "object") return "unknown"; + const schemaUrl = (obj as Record).$schema; + if (typeof schemaUrl !== "string") return "unknown"; + return SCHEMA_ID_MAP[schemaUrl] ?? "unknown"; +} + +export interface ValidateResult { + valid: boolean; + manifest?: PluginManifest; + errors?: ErrorObject[]; +} + +let schemaLoadWarned = false; + +function loadSchema(schemaPath: string): object | null { + try { + return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as object; + } catch (err) { + if (!schemaLoadWarned) { + schemaLoadWarned = true; + console.warn( + `Warning: Could not load JSON schema at ${schemaPath}: ${err instanceof Error ? err.message : err}. Falling back to basic validation.`, + ); + } + return null; + } +} + +let compiledPluginValidator: ReturnType | null = null; + +function getPluginValidator(): ReturnType | null { + if (compiledPluginValidator) return compiledPluginValidator; + const schema = loadSchema(PLUGIN_MANIFEST_SCHEMA_PATH); + if (!schema) return null; + try { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + compiledPluginValidator = ajv.compile(schema); + return compiledPluginValidator; + } catch { + return null; + } +} + +let compiledTemplateValidator: ReturnType | null = null; + +function getTemplateValidator(): ReturnType | null { + if (compiledTemplateValidator) return compiledTemplateValidator; + const pluginSchema = loadSchema(PLUGIN_MANIFEST_SCHEMA_PATH); + const templateSchema = loadSchema(TEMPLATE_PLUGINS_SCHEMA_PATH); + if (!pluginSchema || !templateSchema) return null; + try { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + ajv.addSchema(pluginSchema); + compiledTemplateValidator = ajv.compile(templateSchema); + return compiledTemplateValidator; + } catch { + return null; + } +} + +/** + * Validate a manifest object against the plugin-manifest JSON schema. + * Returns validation result with optional errors for CLI output. + */ +export function validateManifest(obj: unknown): ValidateResult { + if (!obj || typeof obj !== "object") { + return { + valid: false, + errors: [ + { + instancePath: "", + message: "Manifest is not a valid object", + } as ErrorObject, + ], + }; + } + + const validate = getPluginValidator(); + if (!validate) { + const m = obj as Record; + const basicValid = + typeof m.name === "string" && + m.name.length > 0 && + typeof m.displayName === "string" && + m.displayName.length > 0 && + typeof m.description === "string" && + m.description.length > 0 && + m.resources && + typeof m.resources === "object" && + Array.isArray((m.resources as { required?: unknown }).required); + if (basicValid) return { valid: true, manifest: obj as PluginManifest }; + return { + valid: false, + errors: [ + { + instancePath: "", + message: "Invalid manifest structure", + } as ErrorObject, + ], + }; + } + + const valid = validate(obj); + if (valid) return { valid: true, manifest: obj as PluginManifest }; + return { valid: false, errors: validate.errors ?? [] }; +} + +/** + * Validate a template-plugins manifest (appkit.plugins.json) against its schema. + * Registers the plugin-manifest schema first so external $refs resolve. + */ +export function validateTemplateManifest(obj: unknown): ValidateResult { + if (!obj || typeof obj !== "object") { + return { + valid: false, + errors: [ + { + instancePath: "", + message: "Template manifest is not a valid object", + } as ErrorObject, + ], + }; + } + + const validate = getTemplateValidator(); + if (!validate) { + const m = obj as Record; + const basicValid = + typeof m.version === "string" && + m.plugins && + typeof m.plugins === "object"; + if (basicValid) return { valid: true }; + return { + valid: false, + errors: [ + { + instancePath: "", + message: "Invalid template manifest structure", + } as ErrorObject, + ], + }; + } + + const valid = validate(obj); + if (valid) return { valid: true }; + return { valid: false, errors: validate.errors ?? [] }; +} + +/** + * Convert a JSON pointer like /resources/required/0/permission + * to a readable path like resources.required[0].permission + */ +function humanizePath(instancePath: string): string { + if (!instancePath) return "(root)"; + return instancePath + .replace(/^\//, "") + .replace(/\/(\d+)\//g, "[$1].") + .replace(/\/(\d+)$/g, "[$1]") + .replace(/\//g, "."); +} + +/** + * Resolve a JSON pointer to the actual value in the parsed object. + */ +function resolvePointer(obj: unknown, instancePath: string): unknown { + if (!instancePath) return obj; + const segments = instancePath.replace(/^\//, "").split("/"); + let current: unknown = obj; + for (const seg of segments) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[seg]; + } + return current; +} + +/** + * Format schema errors for CLI output. + * Collapses anyOf/oneOf sub-errors into a single message and shows + * the actual invalid value when available. + * + * @param errors - AJV error objects + * @param obj - The original parsed object (optional, used to show actual values) + */ +export function formatValidationErrors( + errors: ErrorObject[], + obj?: unknown, +): string { + const grouped = new Map(); + for (const e of errors) { + const key = e.instancePath || "/"; + if (!grouped.has(key)) grouped.set(key, []); + const list = grouped.get(key); + if (list) list.push(e); + } + + const lines: string[] = []; + + for (const [path, errs] of grouped) { + const readable = humanizePath(path); + const anyOfErr = errs.find( + (e) => e.keyword === "anyOf" || e.keyword === "oneOf", + ); + + if (anyOfErr) { + const enumErrors = errs.filter((e) => e.keyword === "enum"); + if (enumErrors.length > 0) { + const allValues = [ + ...new Set( + enumErrors.flatMap( + (e) => (e.params?.allowedValues as string[]) ?? [], + ), + ), + ]; + const actual = + obj !== undefined ? resolvePointer(obj, path) : undefined; + const valueHint = + actual !== undefined ? ` (got ${JSON.stringify(actual)})` : ""; + lines.push( + ` ${readable}: invalid value${valueHint}`, + ` allowed: ${allValues.join(", ")}`, + ); + continue; + } + } + + for (const e of errs) { + if (e.keyword === "anyOf" || e.keyword === "oneOf") continue; + if (e.keyword === "if") continue; + if (anyOfErr && e.keyword === "enum") continue; + + if (e.keyword === "enum") { + const allowed = (e.params?.allowedValues as string[]) ?? []; + const actual = + obj !== undefined ? resolvePointer(obj, path) : undefined; + const valueHint = + actual !== undefined ? ` (got ${JSON.stringify(actual)})` : ""; + lines.push( + ` ${readable}: invalid value${valueHint}, allowed: ${allowed.join(", ")}`, + ); + } else if (e.keyword === "required") { + lines.push( + ` ${readable}: missing required property "${e.params?.missingProperty}"`, + ); + } else if (e.keyword === "additionalProperties") { + lines.push( + ` ${readable}: unknown property "${e.params?.additionalProperty}"`, + ); + } else if (e.keyword === "pattern") { + const actual = + obj !== undefined ? resolvePointer(obj, path) : undefined; + const valueHint = + actual !== undefined ? ` (got ${JSON.stringify(actual)})` : ""; + lines.push( + ` ${readable}: does not match expected pattern${valueHint}`, + ); + } else if (e.keyword === "type") { + lines.push(` ${readable}: expected type "${e.params?.type}"`); + } else if (e.keyword === "minLength") { + lines.push(` ${readable}: must not be empty`); + } else { + lines.push( + ` ${readable}: ${e.message}${e.params ? ` (${JSON.stringify(e.params)})` : ""}`, + ); + } + } + } + + return lines.join("\n"); +} diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts new file mode 100644 index 00000000..12a44b1e --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { + detectSchemaType, + formatValidationErrors, + validateManifest, + validateTemplateManifest, +} from "./validate-manifest"; + +function resolveManifestPaths(paths: string[], cwd: string): string[] { + const out: string[] = []; + for (const p of paths) { + const resolved = path.resolve(cwd, p); + if (!fs.existsSync(resolved)) { + console.error(`Path not found: ${p}`); + continue; + } + const stat = fs.statSync(resolved); + if (stat.isDirectory()) { + const pluginManifest = path.join(resolved, "manifest.json"); + const templateManifest = path.join(resolved, "appkit.plugins.json"); + let found = false; + if (fs.existsSync(pluginManifest)) { + out.push(pluginManifest); + found = true; + } + if (fs.existsSync(templateManifest)) { + out.push(templateManifest); + found = true; + } + if (!found) { + console.error( + `No manifest.json or appkit.plugins.json in directory: ${p}`, + ); + } + } else { + out.push(resolved); + } + } + return out; +} + +function runPluginValidate(paths: string[]): void { + const cwd = process.cwd(); + const toValidate = paths.length > 0 ? paths : ["."]; + const manifestPaths = resolveManifestPaths(toValidate, cwd); + + if (manifestPaths.length === 0) { + console.error("No manifest files to validate."); + process.exit(1); + } + + let hasFailure = false; + for (const manifestPath of manifestPaths) { + let obj: unknown; + try { + const raw = fs.readFileSync(manifestPath, "utf-8"); + obj = JSON.parse(raw); + } catch (err) { + console.error(`✗ ${manifestPath}`); + console.error(` ${err instanceof Error ? err.message : String(err)}`); + hasFailure = true; + continue; + } + + const schemaType = detectSchemaType(obj); + const result = + schemaType === "template-plugins" + ? validateTemplateManifest(obj) + : validateManifest(obj); + + const relativePath = path.relative(cwd, manifestPath); + if (result.valid) { + console.log(`✓ ${relativePath}`); + } else { + console.error(`✗ ${relativePath}`); + if (result.errors?.length) { + console.error(formatValidationErrors(result.errors, obj)); + } + hasFailure = true; + } + } + + process.exit(hasFailure ? 1 : 0); +} + +export const pluginValidateCommand = new Command("validate") + .description( + "Validate plugin manifest(s) or template manifests against their JSON schema", + ) + .argument( + "[paths...]", + "Paths to manifest.json, appkit.plugins.json, or plugin directories (default: .)", + ) + .action(runPluginValidate); diff --git a/packages/shared/src/cli/commands/plugins.ts b/packages/shared/src/cli/commands/plugins.ts deleted file mode 100644 index ff1de368..00000000 --- a/packages/shared/src/cli/commands/plugins.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command } from "commander"; -import { pluginsSyncCommand } from "./plugins-sync.js"; - -/** - * Parent command for plugin management operations. - * Subcommands: - * - sync: Aggregate plugin manifests into appkit.plugins.json - * - * Future subcommands may include: - * - add: Add a plugin to an existing project - * - remove: Remove a plugin from a project - * - list: List available plugins - */ -export const pluginsCommand = new Command("plugins") - .description("Plugin management commands") - .addCommand(pluginsSyncCommand); diff --git a/packages/shared/src/cli/index.ts b/packages/shared/src/cli/index.ts index 23a19a53..6b3789a6 100644 --- a/packages/shared/src/cli/index.ts +++ b/packages/shared/src/cli/index.ts @@ -7,7 +7,7 @@ import { Command } from "commander"; import { docsCommand } from "./commands/docs.js"; import { generateTypesCommand } from "./commands/generate-types.js"; import { lintCommand } from "./commands/lint.js"; -import { pluginsCommand } from "./commands/plugins.js"; +import { pluginCommand } from "./commands/plugin/index.js"; import { setupCommand } from "./commands/setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -25,6 +25,6 @@ cmd.addCommand(setupCommand); cmd.addCommand(generateTypesCommand); cmd.addCommand(lintCommand); cmd.addCommand(docsCommand); -cmd.addCommand(pluginsCommand); +cmd.addCommand(pluginCommand); cmd.parse(); diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index 8f8c9feb..2411f72e 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -168,24 +168,6 @@ "enum": ["CAN_USE"], "description": "Permission for Databricks App resources" }, - "resourcePermission": { - "type": "string", - "description": "Permission level required for the resource. Valid values depend on resource type.", - "oneOf": [ - { "$ref": "#/$defs/secretPermission" }, - { "$ref": "#/$defs/jobPermission" }, - { "$ref": "#/$defs/sqlWarehousePermission" }, - { "$ref": "#/$defs/servingEndpointPermission" }, - { "$ref": "#/$defs/volumePermission" }, - { "$ref": "#/$defs/vectorSearchIndexPermission" }, - { "$ref": "#/$defs/ucFunctionPermission" }, - { "$ref": "#/$defs/ucConnectionPermission" }, - { "$ref": "#/$defs/databasePermission" }, - { "$ref": "#/$defs/genieSpacePermission" }, - { "$ref": "#/$defs/experimentPermission" }, - { "$ref": "#/$defs/appPermission" } - ] - }, "resourceFieldEntry": { "type": "object", "required": ["env"], @@ -219,13 +201,13 @@ }, "alias": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", + "minLength": 1, "description": "Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.", "examples": ["SQL Warehouse", "Secret", "Vector search index"] }, "resourceKey": { "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", + "pattern": "^[a-z][a-z0-9-]*$", "description": "Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.", "examples": ["sql-warehouse", "database", "secret"] }, @@ -235,7 +217,8 @@ "description": "Human-readable description of why this resource is needed" }, "permission": { - "$ref": "#/$defs/resourcePermission" + "type": "string", + "description": "Required permission level. Validated per resource type by the allOf/if-then rules below." }, "fields": { "type": "object", @@ -246,7 +229,137 @@ "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "secret" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/secretPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "job" } }, + "required": ["type"] + }, + "then": { + "properties": { "permission": { "$ref": "#/$defs/jobPermission" } } + } + }, + { + "if": { + "properties": { "type": { "const": "sql_warehouse" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/sqlWarehousePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "serving_endpoint" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/servingEndpointPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "volume" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/volumePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "vector_search_index" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/vectorSearchIndexPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_function" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/ucFunctionPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "uc_connection" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/ucConnectionPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "database" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/databasePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "genie_space" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/genieSpacePermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "experiment" } }, + "required": ["type"] + }, + "then": { + "properties": { + "permission": { "$ref": "#/$defs/experimentPermission" } + } + } + }, + { + "if": { + "properties": { "type": { "const": "app" } }, + "required": ["type"] + }, + "then": { + "properties": { "permission": { "$ref": "#/$defs/appPermission" } } + } + } + ] }, "configSchemaProperty": { "type": "object", diff --git a/packages/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json index f6bb5ef8..9713e9f6 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -91,89 +91,13 @@ "additionalProperties": false }, "resourceType": { - "type": "string", - "enum": [ - "secret", - "job", - "sql_warehouse", - "serving_endpoint", - "volume", - "vector_search_index", - "uc_function", - "uc_connection", - "database", - "genie_space", - "experiment", - "app" - ], - "description": "Type of Databricks resource" - }, - "resourcePermission": { - "type": "string", - "description": "Permission level required for the resource. Valid values depend on resource type.", - "examples": ["CAN_USE", "CAN_MANAGE", "READ", "WRITE", "EXECUTE"] + "$ref": "plugin-manifest.schema.json#/$defs/resourceType" }, "resourceFieldEntry": { - "type": "object", - "required": ["env"], - "properties": { - "env": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]*$", - "description": "Environment variable name for this field", - "examples": ["DATABRICKS_CACHE_INSTANCE", "SECRET_SCOPE"] - }, - "description": { - "type": "string", - "description": "Human-readable description for this field" - } - }, - "additionalProperties": false + "$ref": "plugin-manifest.schema.json#/$defs/resourceFieldEntry" }, "resourceRequirement": { - "type": "object", - "required": [ - "type", - "alias", - "resourceKey", - "description", - "permission", - "fields" - ], - "properties": { - "type": { - "$ref": "#/$defs/resourceType" - }, - "alias": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Unique alias for this resource within the plugin (UI/display)", - "examples": ["SQL Warehouse", "Secret", "Vector search index"] - }, - "resourceKey": { - "type": "string", - "pattern": "^[a-z][a-zA-Z0-9_]*$", - "description": "Stable key for machine use (env naming, composite keys, app.yaml).", - "examples": ["sql-warehouse", "database", "secret"] - }, - "description": { - "type": "string", - "minLength": 1, - "description": "Human-readable description of why this resource is needed" - }, - "permission": { - "$ref": "#/$defs/resourcePermission" - }, - "fields": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/resourceFieldEntry" - }, - "minProperties": 1, - "description": "Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key)." - } - }, - "additionalProperties": false + "$ref": "plugin-manifest.schema.json#/$defs/resourceRequirement" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe235136..d4489eea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,7 +319,7 @@ importers: version: link:../shared vite: specifier: npm:rolldown-vite@7.1.14 - version: rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + version: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) ws: specifier: ^8.18.3 version: 8.18.3(bufferutil@4.0.9) @@ -341,7 +341,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + version: 5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) packages/appkit-ui: dependencies: @@ -530,6 +530,9 @@ importers: '@ast-grep/napi': specifier: ^0.37.0 version: 0.37.0 + '@clack/prompts': + specifier: ^1.0.1 + version: 1.0.1 ajv: specifier: ^8.17.1 version: 8.17.1 @@ -549,6 +552,9 @@ importers: '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1413,6 +1419,12 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clack/core@1.0.1': + resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} + + '@clack/prompts@1.0.1': + resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -4603,6 +4615,9 @@ packages: '@types/node@24.7.2': resolution: {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -12546,6 +12561,17 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clack/core@1.0.1': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@1.0.1': + dependencies: + '@clack/core': 1.0.1 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@colors/colors@1.5.0': optional: true @@ -16553,6 +16579,10 @@ snapshots: dependencies: undici-types: 7.14.0 + '@types/node@25.2.3': + dependencies: + undici-types: 7.16.0 + '@types/normalize-package-data@2.4.4': {} '@types/oracledb@6.5.2': @@ -16814,7 +16844,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': + '@vitejs/plugin-react@5.1.1(rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -16822,7 +16852,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -22755,7 +22785,7 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - rolldown-vite@7.1.14(@types/node@24.10.1)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + rolldown-vite@7.1.14(@types/node@25.2.3)(esbuild@0.25.10)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@oxc-project/runtime': 0.92.0 fdir: 6.5.0(picomatch@4.0.3) @@ -22765,7 +22795,7 @@ snapshots: rolldown: 1.0.0-beta.41 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 esbuild: 0.25.10 fsevents: 2.3.3 jiti: 2.6.1 diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index 67f3874a..5d5c7a10 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -31,11 +31,11 @@ "displayName": "Server Plugin", "description": "HTTP server with Express, static file serving, and Vite dev mode support", "package": "@databricks/appkit", - "requiredByTemplate": true, "resources": { "required": [], "optional": [] - } + }, + "requiredByTemplate": true } } } diff --git a/template/package.json b/template/package.json index 15e4ea3d..2d30eaec 100644 --- a/template/package.json +++ b/template/package.json @@ -21,8 +21,9 @@ "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", "postinstall": "npm run typegen", - "prebuild": "npm run typegen", - "predev": "npm run typegen", + "prebuild": "npm run sync && npm run typegen", + "predev": "npm run sync && npm run typegen", + "sync": "appkit plugin sync --write --silent", "typegen": "appkit generate-types", "setup": "appkit setup --write" },