From 54bde930723713e40891527c8f5fa22dd6347a4a Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 10 Apr 2026 12:18:53 +0200 Subject: [PATCH] feat: add three-tier plugin stability system (experimental/preview/stable) Add stability field to plugin manifest schema with import path enforcement and full CLI tooling. Experimental plugins may be dropped, preview plugins are heading to stable, stable plugins follow semver strictly. - Schema: stability enum on plugin-manifest and template-plugins schemas - Entrypoints: @databricks/appkit/{experimental,preview} + appkit-ui mirrors - CLI sync: propagate stability, strip requiredByTemplate for non-stable, orphan resource detection, template version bumped to 1.1 - CLI list: always-visible STABILITY column - CLI scaffold: stability prompt during plugin create - CLI promote: new command to promote plugins between tiers - Docs: stability tiers documentation page Signed-off-by: MarioCadenas --- .../client/src/appKitTypes.d.ts | 8 +- docs/docs/plugins/stability.md | 141 +++++++++ docs/static/appkit-ui/styles.gen.css | 28 +- packages/appkit-ui/package.json | 20 ++ packages/appkit-ui/src/js/experimental.ts | 2 + packages/appkit-ui/src/js/preview.ts | 2 + packages/appkit-ui/src/react/experimental.ts | 2 + packages/appkit-ui/src/react/preview.ts | 2 + packages/appkit-ui/tsdown.config.ts | 9 +- packages/appkit/package.json | 10 + packages/appkit/src/experimental.ts | 3 + packages/appkit/src/preview.ts | 3 + packages/appkit/tsdown.config.ts | 2 +- .../src/cli/commands/plugin/create/create.ts | 23 ++ .../commands/plugin/create/scaffold.test.ts | 49 +++ .../cli/commands/plugin/create/scaffold.ts | 1 + .../src/cli/commands/plugin/create/types.ts | 2 + .../shared/src/cli/commands/plugin/index.ts | 5 +- .../src/cli/commands/plugin/list/list.test.ts | 88 ++++++ .../src/cli/commands/plugin/list/list.ts | 10 + .../src/cli/commands/plugin/manifest-types.ts | 2 + .../commands/plugin/promote/promote.test.ts | 184 +++++++++++ .../cli/commands/plugin/promote/promote.ts | 292 ++++++++++++++++++ .../src/cli/commands/plugin/sync/sync.ts | 49 ++- .../src/schemas/plugin-manifest.generated.ts | 4 + .../src/schemas/plugin-manifest.schema.json | 6 + .../src/schemas/template-plugins.schema.json | 8 +- template/appkit.plugins.json | 2 +- 28 files changed, 944 insertions(+), 13 deletions(-) create mode 100644 docs/docs/plugins/stability.md create mode 100644 packages/appkit-ui/src/js/experimental.ts create mode 100644 packages/appkit-ui/src/js/preview.ts create mode 100644 packages/appkit-ui/src/react/experimental.ts create mode 100644 packages/appkit-ui/src/react/preview.ts create mode 100644 packages/appkit/src/experimental.ts create mode 100644 packages/appkit/src/preview.ts create mode 100644 packages/shared/src/cli/commands/plugin/promote/promote.test.ts create mode 100644 packages/shared/src/cli/commands/plugin/promote/promote.ts diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index 0e0ae0b0..43666dd0 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -119,10 +119,10 @@ declare module "@databricks/appkit-ui/react" { result: Array<{ /** @sqlType STRING */ string_value: string; - /** @sqlType STRING */ - number_value: string; - /** @sqlType STRING */ - boolean_value: string; + /** @sqlType INT */ + number_value: number; + /** @sqlType BOOLEAN */ + boolean_value: boolean; /** @sqlType STRING */ date_value: string; /** @sqlType STRING */ diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md new file mode 100644 index 00000000..d3d895bc --- /dev/null +++ b/docs/docs/plugins/stability.md @@ -0,0 +1,141 @@ +--- +sidebar_position: 2 +--- + +# Plugin Stability Tiers + +AppKit plugins have a three-tier stability system that communicates API maturity and breaking-change expectations. + +## Tiers + +| Tier | Import Path | Contract | +|------|------------|---------| +| **Experimental** | `@databricks/appkit/experimental` | Very unstable. May be dropped entirely. No guarantee of promotion. | +| **Preview** | `@databricks/appkit/preview` | API may change between minor releases. On a path to stable. | +| **Stable** | `@databricks/appkit` | Production ready. Follows semver strictly. | + +The import path is the primary stability signal. Importing from `/experimental` or `/preview` is explicit consent to potential breaking changes. + +## Promotion Path + +Promotion is one-way. Plugins can enter at any tier. + +``` +experimental ──→ preview ──→ stable + │ + └──→ (dropped) +``` + +## Usage + +### Importing Plugins by Tier + +```typescript +// Stable plugins +import { server, analytics } from "@databricks/appkit"; + +// Preview plugins +import { somePreviewPlugin } from "@databricks/appkit/preview"; + +// Experimental plugins +import { someExperimentalPlugin } from "@databricks/appkit/experimental"; +``` + +### UI Components + +`@databricks/appkit-ui` mirrors the same pattern: + +```typescript +import { SomeComponent } from "@databricks/appkit-ui/react/preview"; +import { someUtil } from "@databricks/appkit-ui/js/experimental"; +``` + +## CLI Commands + +### Listing Plugins with Stability + +```bash +npx appkit plugin list +``` + +The output includes a STABILITY column showing each plugin's tier. + +### Creating a Plugin with Stability + +```bash +npx appkit plugin create +``` + +The interactive flow prompts for a stability level (defaults to stable). + +### Promoting a Plugin + +```bash +# Promote from experimental to preview +npx appkit plugin promote my-plugin --to preview + +# Promote from preview to stable +npx appkit plugin promote my-plugin --to stable + +# Preview changes without modifying files +npx appkit plugin promote my-plugin --to preview --dry-run +``` + +The promote command: +- Updates the plugin's `manifest.json` stability field +- Rewrites import paths across your project's `.ts`/`.tsx` files +- Runs `plugin sync` to update `appkit.plugins.json` + +**Options:** +- `--dry-run` -- Show what would change without writing +- `--skip-imports` -- Only update the manifest +- `--skip-sync` -- Don't auto-run sync + +## Manifest Field + +The `stability` field in `manifest.json` is optional. When absent, the plugin is considered stable. + +```json +{ + "name": "my-plugin", + "displayName": "My Plugin", + "description": "An experimental feature", + "stability": "experimental", + "resources": { "required": [], "optional": [] } +} +``` + +Valid values: `"experimental"`, `"preview"`, `"stable"`. + +## Template Manifest (appkit.plugins.json) + +When `plugin sync` discovers non-stable plugins, it includes their stability in the output: + +```json +{ + "version": "1.1", + "plugins": { + "my-plugin": { + "name": "my-plugin", + "stability": "experimental", + "package": "@databricks/appkit" + } + } +} +``` + +Only stable plugins can be marked `requiredByTemplate`. Non-stable plugins always remain optional during init. + +## For Third-Party Plugin Authors + +The import paths (`/experimental`, `/preview`) only apply to first-party plugins shipped inside `@databricks/appkit`. Third-party plugins declare stability via the `stability` field in their `manifest.json`. CLI tooling (`plugin list`, `plugin sync`) surfaces this information to users. + +## Current Plugins by Tier + +All built-in plugins are currently **stable**: + +- `server` -- Express HTTP server +- `analytics` -- SQL query execution +- `files` -- Multi-volume file browser +- `genie` -- Genie Space integration +- `lakebase` -- Postgres Autoscaling diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 9a9a38eb..a2192039 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -831,9 +831,6 @@ .max-w-\[calc\(100\%-2rem\)\] { max-width: calc(100% - 2rem); } - .max-w-full { - max-width: 100%; - } .max-w-max { max-width: max-content; } @@ -4514,6 +4511,11 @@ width: calc(var(--spacing) * 5); } } + .\[\&_\[data-slot\=scroll-area-viewport\]\>div\]\:\!block { + & [data-slot=scroll-area-viewport]>div { + display: block !important; + } + } .\[\&_a\]\:underline { & a { text-decoration-line: underline; @@ -4637,11 +4639,26 @@ color: var(--muted-foreground); } } + .\[\&_table\]\:block { + & table { + display: block; + } + } + .\[\&_table\]\:max-w-full { + & table { + max-width: 100%; + } + } .\[\&_table\]\:border-collapse { & table { border-collapse: collapse; } } + .\[\&_table\]\:overflow-x-auto { + & table { + overflow-x: auto; + } + } .\[\&_table\]\:text-xs { & table { font-size: var(--text-xs); @@ -4851,6 +4868,11 @@ width: 100%; } } + .\[\&\>\*\]\:min-w-0 { + &>* { + min-width: calc(var(--spacing) * 0); + } + } .\[\&\>\*\]\:focus-visible\:relative { &>* { &:focus-visible { diff --git a/packages/appkit-ui/package.json b/packages/appkit-ui/package.json index 4463b79d..5980f3f5 100644 --- a/packages/appkit-ui/package.json +++ b/packages/appkit-ui/package.json @@ -27,10 +27,26 @@ "development": "./src/js/index.ts", "default": "./dist/js/index.js" }, + "./js/experimental": { + "development": "./src/js/experimental.ts", + "default": "./dist/js/experimental.js" + }, + "./js/preview": { + "development": "./src/js/preview.ts", + "default": "./dist/js/preview.js" + }, "./react": { "development": "./src/react/index.ts", "default": "./dist/react/index.js" }, + "./react/experimental": { + "development": "./src/react/experimental.ts", + "default": "./dist/react/experimental.js" + }, + "./react/preview": { + "development": "./src/react/preview.ts", + "default": "./dist/react/preview.js" + }, "./package.json": "./package.json", "./styles.css": { "development": "./src/react/styles/globals.css", @@ -111,7 +127,11 @@ "publishConfig": { "exports": { "./js": "./dist/js/index.js", + "./js/experimental": "./dist/js/experimental.js", + "./js/preview": "./dist/js/preview.js", "./react": "./dist/react/index.js", + "./react/experimental": "./dist/react/experimental.js", + "./react/preview": "./dist/react/preview.js", "./package.json": "./package.json", "./styles.css": "./dist/styles.css" } diff --git a/packages/appkit-ui/src/js/experimental.ts b/packages/appkit-ui/src/js/experimental.ts new file mode 100644 index 00000000..8b2205b3 --- /dev/null +++ b/packages/appkit-ui/src/js/experimental.ts @@ -0,0 +1,2 @@ +// Experimental JS utilities -- very unstable, may be dropped entirely. +// Import from '@databricks/appkit-ui/js/preview' once promoted. diff --git a/packages/appkit-ui/src/js/preview.ts b/packages/appkit-ui/src/js/preview.ts new file mode 100644 index 00000000..a407db4a --- /dev/null +++ b/packages/appkit-ui/src/js/preview.ts @@ -0,0 +1,2 @@ +// Preview JS utilities -- APIs may change between minor releases. +// Import from '@databricks/appkit-ui/js' once graduated to stable. diff --git a/packages/appkit-ui/src/react/experimental.ts b/packages/appkit-ui/src/react/experimental.ts new file mode 100644 index 00000000..e8822300 --- /dev/null +++ b/packages/appkit-ui/src/react/experimental.ts @@ -0,0 +1,2 @@ +// Experimental React components -- very unstable, may be dropped entirely. +// Import from '@databricks/appkit-ui/react/preview' once promoted. diff --git a/packages/appkit-ui/src/react/preview.ts b/packages/appkit-ui/src/react/preview.ts new file mode 100644 index 00000000..ef777f6c --- /dev/null +++ b/packages/appkit-ui/src/react/preview.ts @@ -0,0 +1,2 @@ +// Preview React components -- APIs may change between minor releases. +// Import from '@databricks/appkit-ui/react' once graduated to stable. diff --git a/packages/appkit-ui/tsdown.config.ts b/packages/appkit-ui/tsdown.config.ts index f7cb4d4a..b60acc6a 100644 --- a/packages/appkit-ui/tsdown.config.ts +++ b/packages/appkit-ui/tsdown.config.ts @@ -4,7 +4,14 @@ export default defineConfig([ { publint: true, name: "@databricks/appkit-ui", - entry: ["src/js/index.ts", "src/react/index.ts"], + entry: [ + "src/js/index.ts", + "src/js/experimental.ts", + "src/js/preview.ts", + "src/react/index.ts", + "src/react/experimental.ts", + "src/react/preview.ts", + ], outDir: "dist", platform: "browser", minify: false, diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 0613ec51..c0e217dc 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -29,6 +29,14 @@ "development": "./src/index.ts", "default": "./dist/index.js" }, + "./experimental": { + "development": "./src/experimental.ts", + "default": "./dist/experimental.js" + }, + "./preview": { + "development": "./src/preview.ts", + "default": "./dist/preview.js" + }, "./type-generator": { "types": "./dist/type-generator/index.d.ts", "development": "./src/type-generator/index.ts", @@ -92,6 +100,8 @@ "publishConfig": { "exports": { ".": "./dist/index.js", + "./experimental": "./dist/experimental.js", + "./preview": "./dist/preview.js", "./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts", "./type-generator": "./dist/type-generator/index.js", "./package.json": "./package.json" diff --git a/packages/appkit/src/experimental.ts b/packages/appkit/src/experimental.ts new file mode 100644 index 00000000..ac409534 --- /dev/null +++ b/packages/appkit/src/experimental.ts @@ -0,0 +1,3 @@ +// Experimental plugins -- very unstable, may be dropped entirely. +// Plugins here have no guarantee of promotion to preview or stable. +// Import from '@databricks/appkit/preview' once a plugin is promoted. diff --git a/packages/appkit/src/preview.ts b/packages/appkit/src/preview.ts new file mode 100644 index 00000000..0af9abc9 --- /dev/null +++ b/packages/appkit/src/preview.ts @@ -0,0 +1,3 @@ +// Preview plugins -- APIs may change between minor releases. +// These plugins are on a path to stable and will graduate. +// Import from '@databricks/appkit' once a plugin graduates to stable. diff --git a/packages/appkit/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 97698714..ef38c13b 100644 --- a/packages/appkit/tsdown.config.ts +++ b/packages/appkit/tsdown.config.ts @@ -4,7 +4,7 @@ export default defineConfig([ { publint: true, name: "@databricks/appkit", - entry: "src/index.ts", + entry: ["src/index.ts", "src/experimental.ts", "src/preview.ts"], outDir: "dist", hash: false, format: "esm", diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 0be53af3..13705c2e 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -117,6 +117,28 @@ async function runPluginCreate(): Promise { process.exit(0); } + const stability = await select<"stable" | "preview" | "experimental">({ + message: "Plugin stability level", + options: [ + { value: "stable", label: "Stable", hint: "API follows semver" }, + { + value: "preview", + label: "Preview", + hint: "Heading to stable, API may change", + }, + { + value: "experimental", + label: "Experimental", + hint: "Very unstable, may be dropped", + }, + ], + initialValue: "stable" as "stable" | "preview" | "experimental", + }); + if (isCancel(stability)) { + cancel("Cancelled."); + process.exit(0); + } + const resourceTypes = await multiselect({ message: "Which Databricks resources does this plugin need?", options: RESOURCE_TYPE_OPTIONS.map((o) => ({ @@ -153,6 +175,7 @@ async function runPluginCreate(): Promise { name: (name as string).trim(), displayName: (displayName as string).trim(), description: (description as string).trim(), + stability: stability === "stable" ? undefined : stability, resources, version: DEFAULT_VERSION, }; diff --git a/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts index 7283cb89..8b5d0a3e 100644 --- a/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts @@ -222,6 +222,55 @@ describe("scaffold", () => { }); }); + describe("stability field", () => { + it("omits stability from manifest when undefined (defaults to stable)", () => { + 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.stability).toBeUndefined(); + }); + + it('includes stability: "preview" when set', () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin( + targetDir, + { ...BASE_ANSWERS, stability: "preview" }, + { isolated: false }, + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.stability).toBe("preview"); + }); + + it('includes stability: "experimental" when set', () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin( + targetDir, + { ...BASE_ANSWERS, stability: "experimental" }, + { isolated: false }, + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.stability).toBe("experimental"); + }); + }); + describe("rollback on failure", () => { it("cleans up written files when a write fails partway through", () => { const tmp = makeTempDir(); diff --git a/packages/shared/src/cli/commands/plugin/create/scaffold.ts b/packages/shared/src/cli/commands/plugin/create/scaffold.ts index 16b7f376..c5f62b43 100644 --- a/packages/shared/src/cli/commands/plugin/create/scaffold.ts +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.ts @@ -52,6 +52,7 @@ function buildManifest(answers: CreateAnswers): Record { description: answers.description, resources: { required, optional }, }; + if (answers.stability) manifest.stability = answers.stability; if (answers.author) manifest.author = answers.author; manifest.version = answers.version || "0.1.0"; if (answers.license) manifest.license = answers.license; diff --git a/packages/shared/src/cli/commands/plugin/create/types.ts b/packages/shared/src/cli/commands/plugin/create/types.ts index 91eae40a..5cd78b9d 100644 --- a/packages/shared/src/cli/commands/plugin/create/types.ts +++ b/packages/shared/src/cli/commands/plugin/create/types.ts @@ -22,6 +22,8 @@ export interface CreateAnswers { name: string; displayName: string; description: string; + /** Only set when non-stable. Absent = "stable" (default). */ + stability?: "experimental" | "preview"; resources: SelectedResource[]; author?: string; version: string; diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index 04b8cba9..18ddf44a 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { pluginAddResourceCommand } from "./add-resource/add-resource"; import { pluginCreateCommand } from "./create/create"; import { pluginListCommand } from "./list/list"; +import { pluginPromoteCommand } from "./promote/promote"; import { pluginsSyncCommand } from "./sync/sync"; import { pluginValidateCommand } from "./validate/validate"; @@ -13,6 +14,7 @@ import { pluginValidateCommand } from "./validate/validate"; * - 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) + * - promote: Promote a plugin to a higher stability tier */ export const pluginCommand = new Command("plugin") .description("Plugin management commands") @@ -20,4 +22,5 @@ export const pluginCommand = new Command("plugin") .addCommand(pluginCreateCommand) .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) - .addCommand(pluginAddResourceCommand); + .addCommand(pluginAddResourceCommand) + .addCommand(pluginPromoteCommand); diff --git a/packages/shared/src/cli/commands/plugin/list/list.test.ts b/packages/shared/src/cli/commands/plugin/list/list.test.ts index 2315362c..edbdb97d 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.test.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -119,6 +119,58 @@ describe("list", () => { /Failed to parse manifest file/, ); }); + + it("defaults stability to stable when absent", () => { + const tmp = makeTempDir("list-stability-default"); + 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); + for (const row of rows) { + expect(row.stability).toBe("stable"); + } + }); + + it("reads stability field from template manifest", () => { + const tmp = makeTempDir("list-stability-read"); + tempDirs.push(tmp); + const manifest = { + ...TEMPLATE_MANIFEST_JSON, + version: "1.1", + plugins: { + ...TEMPLATE_MANIFEST_JSON.plugins, + preview: { + name: "preview-plugin", + displayName: "Preview Plugin", + package: "@databricks/appkit", + stability: "preview", + resources: { required: [], optional: [] }, + }, + experimental: { + name: "exp-plugin", + displayName: "Experimental Plugin", + package: "@databricks/appkit", + stability: "experimental", + resources: { required: [], optional: [] }, + }, + }, + }; + const manifestPath = path.join(tmp, "appkit.plugins.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const rows = listFromManifestFile(manifestPath); + const previewRow = rows.find((r) => r.name === "preview-plugin"); + const expRow = rows.find((r) => r.name === "exp-plugin"); + const stableRow = rows.find((r) => r.name === "server"); + + expect(previewRow?.stability).toBe("preview"); + expect(expRow?.stability).toBe("experimental"); + expect(stableRow?.stability).toBe("stable"); + }); }); describe("listFromDirectory", () => { @@ -172,6 +224,42 @@ describe("list", () => { expect(rows[0].name).toBe("my-feature"); }); + it("reads stability from manifest in directory scan", async () => { + const tmp = makeTempDir("list-dir-stability"); + tempDirs.push(tmp); + const pluginDir = path.join(tmp, "preview-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "manifest.json"), + JSON.stringify({ + ...PLUGIN_MANIFEST_JSON, + name: "preview-feature", + stability: "preview", + }), + ); + + const rows = await listFromDirectory(tmp, path.dirname(tmp)); + + expect(rows).toHaveLength(1); + expect(rows[0].stability).toBe("preview"); + }); + + it("defaults stability to stable in directory scan when absent", async () => { + const tmp = makeTempDir("list-dir-stability-default"); + tempDirs.push(tmp); + const pluginDir = path.join(tmp, "stable-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "manifest.json"), + JSON.stringify(PLUGIN_MANIFEST_JSON), + ); + + const rows = await listFromDirectory(tmp, path.dirname(tmp)); + + expect(rows).toHaveLength(1); + expect(rows[0].stability).toBe("stable"); + }); + it("does not load JS-only manifests by default", async () => { const tmp = makeTempDir("list-dir-js-disabled"); tempDirs.push(tmp); diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index d9bdc206..76625729 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -16,6 +16,7 @@ export interface PluginRow { name: string; displayName: string; package: string; + stability: "experimental" | "preview" | "stable"; required: number; optional: number; } @@ -36,6 +37,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: string; displayName: string; package: string; + stability?: "experimental" | "preview" | "stable"; resources: { required: unknown[]; optional: unknown[] }; } >; @@ -52,6 +54,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: p.name, displayName: p.displayName ?? p.name, package: p.package ?? "", + stability: p.stability ?? "stable", required: Array.isArray(p.resources?.required) ? p.resources.required.length : 0, @@ -103,10 +106,14 @@ async function collectPluginsRecursive( const packagePath = relPath.startsWith(".") ? relPath : `./${relPath}`; + const rawManifest = manifest as typeof manifest & { + stability?: "experimental" | "preview" | "stable"; + }; rows.push({ name: manifest.name, displayName: manifest.displayName ?? manifest.name, package: packagePath, + stability: rawManifest.stability ?? "stable", required: Array.isArray(manifest.resources?.required) ? manifest.resources.required.length : 0, @@ -153,9 +160,11 @@ function printTable(rows: PluginRow[]): void { 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 maxStab = Math.max(9, ...rows.map((r) => r.stability.length)); const header = [ "NAME".padEnd(maxName), "DISPLAY NAME".padEnd(maxDisplay), + "STABILITY".padEnd(maxStab), "PACKAGE / PATH".padEnd(maxPkg), "REQ", "OPT", @@ -167,6 +176,7 @@ function printTable(rows: PluginRow[]): void { [ r.name.padEnd(maxName), r.displayName.padEnd(maxDisplay), + r.stability.padEnd(maxStab), r.package.padEnd(maxPkg), String(r.required).padStart(3), String(r.optional).padStart(3), diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 1d896f49..b0aab1a9 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -17,6 +17,8 @@ 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; + /** Plugin stability level. Absent or undefined means "stable". */ + stability?: "experimental" | "preview" | "stable"; } export interface TemplatePluginsManifest { diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts new file mode 100644 index 00000000..60a78ee4 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -0,0 +1,184 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "promote-test-")); +} + +function cleanDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +function writeManifest(dir: string, name: string, stability?: string): string { + const pluginDir = path.join(dir, "plugins", name); + fs.mkdirSync(pluginDir, { recursive: true }); + const manifest: Record = { + $schema: + "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + name, + displayName: name.charAt(0).toUpperCase() + name.slice(1), + description: `Test plugin ${name}`, + resources: { required: [], optional: [] }, + }; + if (stability) manifest.stability = stability; + const manifestPath = path.join(pluginDir, "manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + return manifestPath; +} + +describe("promote - manifest mutation", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) cleanDir(dir); + tempDirs.length = 0; + }); + + it("promotes experimental to preview by updating stability field", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const manifestPath = writeManifest(tmp, "my-plugin", "experimental"); + + const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + raw.stability = "preview"; + fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); + + const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(updated.stability).toBe("preview"); + }); + + it("promotes preview to stable by removing stability field", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const manifestPath = writeManifest(tmp, "my-plugin", "preview"); + + const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + delete raw.stability; + fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); + + const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(updated.stability).toBeUndefined(); + }); + + it("promotes experimental directly to stable", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const manifestPath = writeManifest(tmp, "my-plugin", "experimental"); + + const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + delete raw.stability; + fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); + + const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(updated.stability).toBeUndefined(); + }); +}); + +describe("promote - validation", () => { + it("rejects demotion (stable to preview)", () => { + const tiers = { experimental: 0, preview: 1, stable: 2 }; + const current = "stable"; + const target = "preview"; + expect(tiers[target] <= tiers[current]).toBe(true); + }); + + it("rejects demotion (preview to experimental)", () => { + const tiers = { experimental: 0, preview: 1, stable: 2 }; + const current = "preview"; + const target = "experimental"; + expect(tiers[target] <= tiers[current]).toBe(true); + }); + + it("rejects no-op (already at target)", () => { + const current = "preview"; + const target = "preview"; + expect(current === target).toBe(true); + }); + + it("accepts valid upward promotions", () => { + const tiers = { experimental: 0, preview: 1, stable: 2 }; + expect(tiers.preview > tiers.experimental).toBe(true); + expect(tiers.stable > tiers.preview).toBe(true); + expect(tiers.stable > tiers.experimental).toBe(true); + }); +}); + +describe("promote - import rewriting", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) cleanDir(dir); + tempDirs.length = 0; + }); + + it("rewrites @databricks/appkit/experimental to /preview", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const tsFile = path.join(tmp, "server.ts"); + fs.writeFileSync( + tsFile, + `import { myPlugin } from "@databricks/appkit/experimental";\n`, + ); + + const content = fs.readFileSync(tsFile, "utf-8"); + const updated = content + .split("@databricks/appkit/experimental") + .join("@databricks/appkit/preview"); + fs.writeFileSync(tsFile, updated); + + expect(fs.readFileSync(tsFile, "utf-8")).toContain( + "@databricks/appkit/preview", + ); + expect(fs.readFileSync(tsFile, "utf-8")).not.toContain( + "@databricks/appkit/experimental", + ); + }); + + it("rewrites @databricks/appkit/preview to @databricks/appkit", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const tsFile = path.join(tmp, "server.ts"); + fs.writeFileSync( + tsFile, + `import { myPlugin } from "@databricks/appkit/preview";\n`, + ); + + const content = fs.readFileSync(tsFile, "utf-8"); + const updated = content + .split("@databricks/appkit/preview") + .join("@databricks/appkit"); + fs.writeFileSync(tsFile, updated); + + const result = fs.readFileSync(tsFile, "utf-8"); + expect(result).toContain('"@databricks/appkit"'); + expect(result).not.toContain("/preview"); + }); + + it("rewrites appkit-ui paths alongside appkit paths", () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const tsFile = path.join(tmp, "app.tsx"); + fs.writeFileSync( + tsFile, + [ + `import { Comp } from "@databricks/appkit-ui/react/experimental";`, + `import { util } from "@databricks/appkit-ui/js/experimental";`, + ].join("\n"), + ); + + const content = fs.readFileSync(tsFile, "utf-8"); + const updated = content.split("/experimental").join("/preview"); + fs.writeFileSync(tsFile, updated); + + const result = fs.readFileSync(tsFile, "utf-8"); + expect(result).toContain("@databricks/appkit-ui/react/preview"); + expect(result).toContain("@databricks/appkit-ui/js/preview"); + expect(result).not.toContain("/experimental"); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts new file mode 100644 index 00000000..816ac27a --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -0,0 +1,292 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { + loadManifestFromFile, + resolveManifestInDir, +} from "../manifest-resolve"; +import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; +import { validateManifest } from "../validate/validate-manifest"; + +type Stability = "experimental" | "preview" | "stable"; + +const TIER_ORDER: Record = { + experimental: 0, + preview: 1, + stable: 2, +}; + +const IMPORT_PATH_MAP: Record = { + experimental: "/experimental", + preview: "/preview", + stable: "", +}; + +const MAX_SCAN_DEPTH = 5; + +interface PromoteResult { + manifestPath: string; + oldStability: Stability; + newStability: Stability; + importRewrites: { file: string; from: string; to: string }[]; +} + +function findPluginManifest( + pluginName: string, + cwd: string, +): { manifestPath: string; isLocal: boolean } | null { + const dirsToScan = ["plugins", "server", "."]; + + for (const dir of dirsToScan) { + const absDir = path.resolve(cwd, dir); + const result = scanDirForPlugin(absDir, pluginName, cwd, 0); + if (result) return { manifestPath: result, isLocal: true }; + } + + const nodeModulesDir = path.join(cwd, "node_modules", "@databricks/appkit"); + if (fs.existsSync(nodeModulesDir)) { + const pluginsDir = path.join(nodeModulesDir, "dist", "plugins"); + if (fs.existsSync(pluginsDir)) { + const manifestPath = path.join(pluginsDir, pluginName, "manifest.json"); + if (fs.existsSync(manifestPath)) { + return { manifestPath, isLocal: false }; + } + } + } + + return null; +} + +function scanDirForPlugin( + dir: string, + pluginName: string, + cwd: string, + depth: number, +): string | null { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null; + if (depth >= MAX_SCAN_DEPTH) return null; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const childPath = path.join(dir, entry.name); + const allowJs = shouldAllowJsManifestForDir(childPath); + const resolved = resolveManifestInDir(childPath, { + allowJsManifest: allowJs, + }); + + if (resolved) { + try { + const obj = loadManifestFromFileSync(resolved.path); + if (obj && typeof obj === "object" && "name" in obj) { + if ((obj as { name: string }).name === pluginName) { + return resolved.path; + } + } + } catch { + // skip + } + continue; + } + + const deeper = scanDirForPlugin(childPath, pluginName, cwd, depth + 1); + if (deeper) return deeper; + } + return null; +} + +function loadManifestFromFileSync(filePath: string): unknown { + const raw = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(raw); +} + +function rewriteImportsInFile( + filePath: string, + oldSuffix: string, + newSuffix: string, + dryRun: boolean, +): { file: string; from: string; to: string } | null { + const content = fs.readFileSync(filePath, "utf-8"); + + const packages = [ + "@databricks/appkit", + "@databricks/appkit-ui/js", + "@databricks/appkit-ui/react", + ]; + let updated = content; + let changed = false; + + for (const pkg of packages) { + const oldPath = `${pkg}${oldSuffix}`; + const newPath = `${pkg}${newSuffix}`; + if (updated.includes(oldPath)) { + updated = updated.split(oldPath).join(newPath); + changed = true; + } + } + + if (!changed) return null; + + if (!dryRun) { + fs.writeFileSync(filePath, updated); + } + + return { + file: filePath, + from: oldSuffix || "(root)", + to: newSuffix || "(root)", + }; +} + +function findTsFiles(dir: string, depth = 0): string[] { + if (depth >= 10) return []; + if (!fs.existsSync(dir)) return []; + + const results: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === "node_modules" || + entry.name === "dist" || + entry.name === ".git" + ) + continue; + results.push(...findTsFiles(fullPath, depth + 1)); + } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { + results.push(fullPath); + } + } + + return results; +} + +async function runPromote( + pluginName: string, + options: { + to: string; + dryRun?: boolean; + skipImports?: boolean; + skipSync?: boolean; + }, +): Promise { + const cwd = process.cwd(); + const target = options.to as Stability; + + if (!["experimental", "preview", "stable"].includes(target)) { + console.error( + `Invalid target tier "${target}". Must be one of: experimental, preview, stable`, + ); + process.exit(1); + } + + const found = findPluginManifest(pluginName, cwd); + if (!found) { + console.error( + `Plugin "${pluginName}" not found. Searched local dirs and node_modules.`, + ); + process.exit(1); + } + + const { manifestPath } = found; + const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + const currentStability: Stability = raw.stability ?? "stable"; + + if (currentStability === target) { + console.error( + `Plugin "${pluginName}" is already at "${target}". Nothing to do.`, + ); + process.exit(1); + } + + if (TIER_ORDER[target] <= TIER_ORDER[currentStability]) { + console.error( + `Cannot demote "${pluginName}" from "${currentStability}" to "${target}". Promotion is one-way only.`, + ); + process.exit(1); + } + + const prefix = options.dryRun ? "[dry-run] " : ""; + + // Update manifest + if (target === "stable") { + delete raw.stability; + } else { + raw.stability = target; + } + + if (!options.dryRun) { + fs.writeFileSync(manifestPath, `${JSON.stringify(raw, null, 2)}\n`); + } + console.log( + `${prefix}Updated manifest: ${path.relative(cwd, manifestPath)} (${currentStability} → ${target})`, + ); + + // Rewrite imports + const importRewrites: { file: string; from: string; to: string }[] = []; + if (!options.skipImports) { + const oldSuffix = IMPORT_PATH_MAP[currentStability]; + const newSuffix = IMPORT_PATH_MAP[target]; + + const tsFiles = findTsFiles(cwd); + for (const file of tsFiles) { + const result = rewriteImportsInFile( + file, + oldSuffix, + newSuffix, + Boolean(options.dryRun), + ); + if (result) { + importRewrites.push(result); + console.log( + `${prefix}Rewritten imports in: ${path.relative(cwd, file)}`, + ); + } + } + + if (importRewrites.length === 0) { + console.log(`${prefix}No import paths to rewrite.`); + } + } + + // Auto-sync + if (!options.skipSync && !options.dryRun) { + console.log(`\n${prefix}Running plugin sync...`); + const { execSync } = await import("node:child_process"); + try { + execSync("npx appkit plugin sync --write", { + cwd, + stdio: "inherit", + }); + } catch { + console.warn("Warning: plugin sync failed. Run manually."); + } + } + + console.log( + `\n${prefix}Promotion complete: ${pluginName} ${currentStability} → ${target}`, + ); + if (importRewrites.length > 0) { + console.log(` ${importRewrites.length} file(s) with import rewrites`); + } +} + +export const pluginPromoteCommand = new Command("promote") + .description("Promote a plugin to a higher stability tier") + .argument("", "Plugin name to promote") + .requiredOption( + "--to ", + "Target stability tier (experimental, preview, stable)", + ) + .option("--dry-run", "Show what would change without modifying files") + .option("--skip-imports", "Only update manifest, skip import path rewriting") + .option("--skip-sync", "Don't auto-run plugin sync after promotion") + .action((pluginName, opts) => + runPromote(pluginName, opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..79352224 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -84,6 +84,10 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.stability && + manifest.stability !== "stable" && { + stability: manifest.stability as "experimental" | "preview", + }), }, ]; } @@ -525,7 +529,7 @@ function writeManifest( const templateManifest: TemplatePluginsManifest = { $schema: "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - version: "1.0", + version: "1.1", plugins, }; @@ -744,6 +748,17 @@ async function runPluginsSync(options: { } } + // Step 6b: Strip requiredByTemplate for non-stable plugins + for (const plugin of Object.values(plugins)) { + if ( + plugin.requiredByTemplate && + plugin.stability && + plugin.stability !== "stable" + ) { + plugin.requiredByTemplate = undefined; + } + } + if (!options.silent) { console.log(`\nFound ${pluginCount} plugin(s):`); for (const [name, manifest] of Object.entries(plugins)) { @@ -758,6 +773,38 @@ async function runPluginsSync(options: { } } + // Step 7: Detect orphaned resources from removed plugins + if (!options.silent && fs.existsSync(outputPath)) { + try { + const oldRaw = fs.readFileSync(outputPath, "utf-8"); + const oldManifest = JSON.parse(oldRaw) as TemplatePluginsManifest; + const oldNames = new Set(Object.keys(oldManifest.plugins ?? {})); + const newNames = new Set(Object.keys(plugins)); + for (const name of oldNames) { + if (newNames.has(name)) continue; + const oldPlugin = oldManifest.plugins[name]; + const envVars: string[] = []; + for (const res of [ + ...(oldPlugin.resources?.required ?? []), + ...(oldPlugin.resources?.optional ?? []), + ]) { + if (res.fields) { + for (const field of Object.values(res.fields)) { + if (field.env) envVars.push(field.env); + } + } + } + const envInfo = + envVars.length > 0 + ? ` The following resource env vars may be orphaned: ${envVars.join(", ")}` + : ""; + console.warn(`Warning: Plugin "${name}" was removed.${envInfo}`); + } + } catch { + // Ignore parse errors on existing manifest + } + } + writeManifest(outputPath, { plugins }, options); } diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts index 5d2e5d4a..90ce5298 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -214,6 +214,10 @@ export interface PluginManifest { * When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync. */ hidden?: boolean; + /** + * Plugin stability level. Experimental plugins are very unstable and may be dropped. Preview plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly. + */ + stability?: "experimental" | "preview" | "stable"; } /** * Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key). diff --git a/packages/shared/src/schemas/plugin-manifest.schema.json b/packages/shared/src/schemas/plugin-manifest.schema.json index ed4ef573..20bdbdc3 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -95,6 +95,12 @@ "type": "boolean", "default": false, "description": "When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync." + }, + "stability": { + "type": "string", + "enum": ["experimental", "preview", "stable"], + "default": "stable", + "description": "Plugin stability level. Experimental plugins are very unstable and may be dropped. Preview plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly." } }, "additionalProperties": false, diff --git a/packages/shared/src/schemas/template-plugins.schema.json b/packages/shared/src/schemas/template-plugins.schema.json index 290edd05..689a6f0a 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -12,7 +12,7 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "1.1"], "description": "Schema version for the template plugins manifest" }, "plugins": { @@ -69,6 +69,12 @@ "type": "string", "description": "Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning)." }, + "stability": { + "type": "string", + "enum": ["experimental", "preview", "stable"], + "default": "stable", + "description": "Plugin stability level. Experimental may be dropped. Preview is heading to stable. Stable follows semver." + }, "resources": { "type": "object", "required": ["required", "optional"], diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index cf60a8af..536cb632 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -1,6 +1,6 @@ { "$schema": "https://databricks.github.io/appkit/schemas/template-plugins.schema.json", - "version": "1.0", + "version": "1.1", "plugins": { "analytics": { "name": "analytics",