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",