From 92daf01b26bcee56d96864935a4b9fe8fda03084 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 17:02:41 +0200 Subject: [PATCH 01/17] feat: add plugin stability system with import-path enforcement Add a `stability` field to plugin manifests with full CLI tooling. Plugins declare their tier in manifest.json; the import path (`@databricks/appkit/` for non-stable, `@databricks/appkit` for stable) enforces the contract at the package boundary so consumers cannot accidentally take a dependency on an unstable API. This commit lands the initial three-tier implementation (experimental/preview/stable). A follow-up commit on this branch collapses to two tiers (beta/stable); the per-commit history shows the evolution. - Schema: stability enum on plugin-manifest and template-plugins schemas - Entrypoints: per-tier subpath exports in appkit and appkit-ui - 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 --- docs/docs/plugins/stability.md | 141 +++++++++ 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 | 6 +- .../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 +- 26 files changed, 916 insertions(+), 6 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/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md new file mode 100644 index 000000000..d3d895bcb --- /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/packages/appkit-ui/package.json b/packages/appkit-ui/package.json index 3cc2b3bba..06d205c0f 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 000000000..8b2205b36 --- /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 000000000..a407db4a0 --- /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 000000000..e88223001 --- /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 000000000..ef777f6ca --- /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 f7cb4d4ac..b60acc6a5 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 dc80f936f..e3bc9d979 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", @@ -95,6 +103,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 000000000..ac4095342 --- /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 000000000..0af9abc96 --- /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 976987142..ef38c13b3 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 2d516bedf..6e558a1cb 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -333,6 +333,28 @@ async function runInteractive(): 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) => ({ @@ -369,6 +391,7 @@ async function runInteractive(): 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 7283cb894..8b5d0a3e5 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 16b7f3766..c5f62b435 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 91eae40a5..5cd78b9d2 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 d2ff00a96..165d1a583 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") @@ -21,6 +23,7 @@ export const pluginCommand = new Command("plugin") .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) .addCommand(pluginAddResourceCommand) + .addCommand(pluginPromoteCommand) .addHelpText( "after", ` @@ -29,5 +32,6 @@ Examples: $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" $ appkit plugin validate . $ appkit plugin list --json - $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse`, + $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse + $ appkit plugin promote my-plugin --to preview`, ); 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 2315362c6..edbdb97df 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 e9a3b35e5..b0928de26 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 1d896f493..b0aab1a95 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 000000000..60a78ee40 --- /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 000000000..816ac27ae --- /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 2cfaff4ef..1c1741317 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, }; @@ -752,6 +756,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 && !options.json) { console.log(`\nFound ${pluginCount} plugin(s):`); for (const [name, manifest] of Object.entries(plugins)) { @@ -766,6 +781,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 5d2e5d4a1..90ce52989 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 ed4ef5731..20bdbdc3d 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 290edd059..689a6f0a9 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 9f4d03c58..d3c8702f9 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", From 7ee3d2f4e877c1fdfbef72ecbd9d2d38d437a551 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 27 Apr 2026 11:20:13 +0200 Subject: [PATCH 02/17] fix(plugin): harden promote command against path traversal & sync failures Address review findings on the three-tier plugin stability system: Security: - Validate plugin name against npm-package charset (rejects "..", path separators, NUL, backslash; allows @scope/name) - Bound the node_modules manifest lookup with isWithinDirectory so a typo'd or malicious name cannot escape dist/plugins - Refuse to mutate manifests under node_modules unless --allow-installed is passed (reinstall would silently revert otherwise) - Replace console.error(err) in the action handler with a fixed message and a DEBUG-gated full-error dump to avoid leaking absolute paths / stack frames into telemetry Correctness: - Validate the manifest's stability field before any mutation; reject unknown values (e.g. "Stable") instead of silently treating them as stable and writing back - Make a failed post-promote 'plugin sync' exit non-zero and skip the success message - Guard sync.ts orphan detection against null / non-object plugin entries in a stale appkit.plugins.json Robustness: - Skip symlinked directories during the project walk (prevents both cycles and out-of-tree rewrites) - Expand the directory-skip list (.git, .turbo, .next, .nuxt, .cache, .svelte-kit, .vite, .parcel-cache, coverage, build, out) - Align findTsFiles depth cap with sync.ts MAX_SCAN_DEPTH - Add boundary check inside the recursive TS walk Tests: - Replace placeholder tests with real runPromote integration tests covering invalid names, path-traversal attempts, unknown tiers, invalid manifest stability, demotion, no-op, dry-run, import rewriting, symlink boundaries, and the node_modules guard Signed-off-by: MarioCadenas --- .../commands/plugin/promote/promote.test.ts | 425 +++++++++++++----- .../cli/commands/plugin/promote/promote.ts | 212 +++++++-- .../src/cli/commands/plugin/sync/sync.ts | 7 +- 3 files changed, 492 insertions(+), 152 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts index 60a78ee40..1cc21409c 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -1,7 +1,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + IMPORT_PATH_MAP, + isStability, + PLUGIN_NAME_PATTERN, + rewriteImportsInFile, + runPromote, + TIER_ORDER, + validatePluginName, +} from "./promote"; function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "promote-test-")); @@ -15,8 +24,13 @@ function cleanDir(dir: string): void { } } -function writeManifest(dir: string, name: string, stability?: string): string { - const pluginDir = path.join(dir, "plugins", name); +function writeManifest( + dir: string, + name: string, + stability?: string, + subdir = "plugins", +): string { + const pluginDir = path.join(dir, subdir, name); fs.mkdirSync(pluginDir, { recursive: true }); const manifest: Record = { $schema: @@ -26,159 +40,356 @@ function writeManifest(dir: string, name: string, stability?: string): string { description: `Test plugin ${name}`, resources: { required: [], optional: [] }, }; - if (stability) manifest.stability = stability; + if (stability !== undefined) 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[] = []; +interface PromoteHarness { + cwd: string; + cleanup: () => void; + errors: string[]; + logs: string[]; +} - afterEach(() => { - for (const dir of tempDirs) cleanDir(dir); - tempDirs.length = 0; - }); +function setupHarness(): PromoteHarness { + const tmp = makeTempDir(); + const originalCwd = process.cwd(); + process.chdir(tmp); + const errors: string[] = []; + const logs: string[] = []; + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + throw new Error(`__exit:${code ?? 0}`); + }) as never); + const errSpy = vi + .spyOn(console, "error") + .mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + const logSpy = vi + .spyOn(console, "log") + .mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }); + return { + cwd: tmp, + errors, + logs, + cleanup: () => { + process.chdir(originalCwd); + exitSpy.mockRestore(); + errSpy.mockRestore(); + logSpy.mockRestore(); + cleanDir(tmp); + }, + }; +} - it("promotes experimental to preview by updating stability field", () => { - const tmp = makeTempDir(); - tempDirs.push(tmp); - const manifestPath = writeManifest(tmp, "my-plugin", "experimental"); +describe("validatePluginName", () => { + it.each([ + "my-plugin", + "my_plugin", + "my.plugin", + "plugin42", + "@databricks/files", + "@scope/foo-bar", + ])("accepts valid name: %s", (name) => { + expect(() => validatePluginName(name)).not.toThrow(); + }); - const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - raw.stability = "preview"; - fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); + it.each([ + ["..", "traversal"], + ["../etc/passwd", "traversal"], + ["foo/../bar", "traversal"], + ["a/b", "unscoped slash"], + ["foo\0bar", "null byte"], + ["", "empty"], + ["plugin name", "space"], + ["@/foo", "empty scope"], + ["@scope", "scope without name"], + ["plugin\\name", "backslash"], + ])("rejects invalid name: %s (%s)", (name) => { + expect(() => validatePluginName(name)).toThrow(); + }); - const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(updated.stability).toBe("preview"); + it("PLUGIN_NAME_PATTERN matches the validator", () => { + expect(PLUGIN_NAME_PATTERN.test("my-plugin")).toBe(true); + expect(PLUGIN_NAME_PATTERN.test("@scope/name")).toBe(true); + expect(PLUGIN_NAME_PATTERN.test("not/scoped")).toBe(false); }); +}); - it("promotes preview to stable by removing stability field", () => { - const tmp = makeTempDir(); - tempDirs.push(tmp); - const manifestPath = writeManifest(tmp, "my-plugin", "preview"); +describe("isStability", () => { + it("accepts the three tiers", () => { + expect(isStability("experimental")).toBe(true); + expect(isStability("preview")).toBe(true); + expect(isStability("stable")).toBe(true); + }); - const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - delete raw.stability; - fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); + it("rejects everything else", () => { + expect(isStability("Stable")).toBe(false); + expect(isStability("STABLE")).toBe(false); + expect(isStability("alpha")).toBe(false); + expect(isStability(undefined)).toBe(false); + expect(isStability(null)).toBe(false); + expect(isStability(1)).toBe(false); + }); +}); - const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(updated.stability).toBeUndefined(); +describe("TIER_ORDER", () => { + it("orders experimental < preview < stable", () => { + expect(TIER_ORDER.experimental).toBeLessThan(TIER_ORDER.preview); + expect(TIER_ORDER.preview).toBeLessThan(TIER_ORDER.stable); }); - it("promotes experimental directly to stable", () => { - const tmp = makeTempDir(); - tempDirs.push(tmp); - const manifestPath = writeManifest(tmp, "my-plugin", "experimental"); + it("IMPORT_PATH_MAP returns empty string for stable (root entrypoint)", () => { + expect(IMPORT_PATH_MAP.stable).toBe(""); + expect(IMPORT_PATH_MAP.experimental).toBe("/experimental"); + expect(IMPORT_PATH_MAP.preview).toBe("/preview"); + }); +}); - const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - delete raw.stability; - fs.writeFileSync(manifestPath, JSON.stringify(raw, null, 2)); +describe("rewriteImportsInFile", () => { + let tmp: string; - const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(updated.stability).toBeUndefined(); + beforeEach(() => { + tmp = makeTempDir(); }); -}); -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); + afterEach(() => { + cleanDir(tmp); }); - 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("rewrites a matching import", () => { + const file = path.join(tmp, "server.ts"); + fs.writeFileSync( + file, + `import { x } from "@databricks/appkit/experimental";\n`, + ); + const result = rewriteImportsInFile( + file, + "/experimental", + "/preview", + false, + ); + expect(result).not.toBeNull(); + expect(fs.readFileSync(file, "utf-8")).toContain( + "@databricks/appkit/preview", + ); }); - it("rejects no-op (already at target)", () => { - const current = "preview"; - const target = "preview"; - expect(current === target).toBe(true); + it("dry-run does not write the file", () => { + const file = path.join(tmp, "server.ts"); + const original = `import { x } from "@databricks/appkit/experimental";\n`; + fs.writeFileSync(file, original); + const result = rewriteImportsInFile( + file, + "/experimental", + "/preview", + true, + ); + expect(result).not.toBeNull(); + expect(fs.readFileSync(file, "utf-8")).toBe(original); }); - 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); + it("returns null when no rewrite is needed", () => { + const file = path.join(tmp, "server.ts"); + fs.writeFileSync(file, `import { x } from "express";\n`); + const result = rewriteImportsInFile( + file, + "/experimental", + "/preview", + false, + ); + expect(result).toBeNull(); }); }); -describe("promote - import rewriting", () => { - const tempDirs: string[] = []; +describe("runPromote", () => { + let h: PromoteHarness; + + beforeEach(() => { + h = setupHarness(); + }); afterEach(() => { - for (const dir of tempDirs) cleanDir(dir); - tempDirs.length = 0; + h.cleanup(); + }); + + it("rejects an invalid plugin name (path traversal attempt)", async () => { + await expect( + runPromote("../etc/passwd", { to: "preview", skipSync: true }), + ).rejects.toThrow(/Invalid plugin name/); + }); + + it("rejects an invalid target tier", async () => { + writeManifest(h.cwd, "my-plugin", "experimental"); + await expect( + runPromote("my-plugin", { to: "ALPHA", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /Invalid target tier/.test(e))).toBe(true); + }); + + it("rejects when plugin is not found", async () => { + await expect( + runPromote("ghost", { to: "preview", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /not found/.test(e))).toBe(true); + }); + + it("rejects an invalid stability value in the manifest (cased 'Stable')", async () => { + writeManifest(h.cwd, "my-plugin", "Stable"); + await expect( + runPromote("my-plugin", { to: "preview", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe(true); + }); + + it("rejects demotion when manifest is at a higher valid tier", async () => { + writeManifest(h.cwd, "my-plugin", "preview"); + await expect( + runPromote("my-plugin", { to: "experimental", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /Cannot demote/.test(e))).toBe(true); + }); + + it("rejects demotion from stable (absent stability) to preview", async () => { + writeManifest(h.cwd, "my-plugin"); + await expect( + runPromote("my-plugin", { to: "preview", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /Cannot demote/.test(e))).toBe(true); }); - it("rewrites @databricks/appkit/experimental to /preview", () => { - const tmp = makeTempDir(); - tempDirs.push(tmp); - const tsFile = path.join(tmp, "server.ts"); + it("rejects no-op (already at target)", async () => { + writeManifest(h.cwd, "my-plugin", "preview"); + await expect( + runPromote("my-plugin", { to: "preview", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /already at "preview"/.test(e))).toBe(true); + }); + + it("promotes experimental to preview and updates manifest", async () => { + const manifestPath = writeManifest(h.cwd, "my-plugin", "experimental"); + await runPromote("my-plugin", { + to: "preview", + skipSync: true, + skipImports: true, + }); + const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(updated.stability).toBe("preview"); + }); + + it("promotes preview to stable by removing the stability field", async () => { + const manifestPath = writeManifest(h.cwd, "my-plugin", "preview"); + await runPromote("my-plugin", { + to: "stable", + skipSync: true, + skipImports: true, + }); + const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(updated.stability).toBeUndefined(); + }); + + it("dry-run does not mutate the manifest", async () => { + const manifestPath = writeManifest(h.cwd, "my-plugin", "experimental"); + const before = fs.readFileSync(manifestPath, "utf-8"); + await runPromote("my-plugin", { + to: "preview", + dryRun: true, + skipSync: true, + skipImports: true, + }); + expect(fs.readFileSync(manifestPath, "utf-8")).toBe(before); + }); + + it("rewrites imports across .ts and .tsx files in the project", async () => { + writeManifest(h.cwd, "my-plugin", "experimental"); + const tsFile = path.join(h.cwd, "server", "server.ts"); + fs.mkdirSync(path.dirname(tsFile), { recursive: true }); fs.writeFileSync( tsFile, `import { myPlugin } from "@databricks/appkit/experimental";\n`, ); + const tsxFile = path.join(h.cwd, "client", "App.tsx"); + fs.mkdirSync(path.dirname(tsxFile), { recursive: true }); + fs.writeFileSync( + tsxFile, + `import { Comp } from "@databricks/appkit-ui/react/experimental";\n`, + ); - const content = fs.readFileSync(tsFile, "utf-8"); - const updated = content - .split("@databricks/appkit/experimental") - .join("@databricks/appkit/preview"); - fs.writeFileSync(tsFile, updated); + await runPromote("my-plugin", { to: "preview", skipSync: true }); expect(fs.readFileSync(tsFile, "utf-8")).toContain( "@databricks/appkit/preview", ); - expect(fs.readFileSync(tsFile, "utf-8")).not.toContain( - "@databricks/appkit/experimental", + expect(fs.readFileSync(tsxFile, "utf-8")).toContain( + "@databricks/appkit-ui/react/preview", ); }); - 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`, - ); + it("skips symlinked directories during the project walk", async () => { + writeManifest(h.cwd, "my-plugin", "experimental"); + const realOutside = makeTempDir(); + try { + const outsideTs = path.join(realOutside, "leak.ts"); + fs.writeFileSync( + outsideTs, + `import { x } from "@databricks/appkit/experimental";\n`, + ); + const link = path.join(h.cwd, "linked"); + try { + fs.symlinkSync(realOutside, link, "dir"); + } catch { + return; + } - const content = fs.readFileSync(tsFile, "utf-8"); - const updated = content - .split("@databricks/appkit/preview") - .join("@databricks/appkit"); - fs.writeFileSync(tsFile, updated); + await runPromote("my-plugin", { to: "preview", skipSync: true }); - const result = fs.readFileSync(tsFile, "utf-8"); - expect(result).toContain('"@databricks/appkit"'); - expect(result).not.toContain("/preview"); + expect(fs.readFileSync(outsideTs, "utf-8")).toContain( + "@databricks/appkit/experimental", + ); + } finally { + cleanDir(realOutside); + } }); - 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"), + it("refuses to mutate a node_modules-only manifest without --allow-installed", async () => { + const manifestPath = writeManifest( + h.cwd, + "installed-plugin", + "experimental", + "node_modules/@databricks/appkit/dist/plugins", ); + await expect( + runPromote("installed-plugin", { + to: "preview", + skipSync: true, + skipImports: true, + }), + ).rejects.toThrow(/__exit:1/); + expect( + h.errors.some((e) => /Refusing to mutate an installed package/.test(e)), + ).toBe(true); + const after = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + expect(after.stability).toBe("experimental"); + }); - 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"); + it("rejects a path-traversal name even if a manifest exists outside node_modules/dist/plugins", async () => { + fs.mkdirSync( + path.join(h.cwd, "node_modules", "@databricks", "appkit", "dist"), + { recursive: true }, + ); + fs.mkdirSync(path.join(h.cwd, "elsewhere"), { recursive: true }); + fs.writeFileSync( + path.join(h.cwd, "elsewhere", "manifest.json"), + JSON.stringify({ name: "ghost", stability: "experimental" }), + ); + await expect( + runPromote("../../../elsewhere", { to: "preview", skipSync: true }), + ).rejects.toThrow(/Invalid plugin name/); }); }); diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 816ac27ae..fd17da8df 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -2,12 +2,9 @@ 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 { resolveManifestInDir } from "../manifest-resolve"; +import { isWithinDirectory } from "../sync/sync"; import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; -import { validateManifest } from "../validate/validate-manifest"; type Stability = "experimental" | "preview" | "stable"; @@ -23,13 +20,65 @@ const IMPORT_PATH_MAP: Record = { stable: "", }; +/** Aligned with sync.ts and list.ts; keep all plugin-tree walks at the same cap. */ const MAX_SCAN_DEPTH = 5; -interface PromoteResult { - manifestPath: string; - oldStability: Stability; - newStability: Stability; - importRewrites: { file: string; from: string; to: string }[]; +/** + * Directories that should never be walked when discovering plugin manifests + * or rewriting imports. Mirrors common build/output trees. + */ +const SKIP_DIRECTORIES = new Set([ + "node_modules", + "dist", + "build", + "out", + ".git", + ".turbo", + ".next", + ".nuxt", + ".cache", + ".svelte-kit", + ".vite", + ".parcel-cache", + "coverage", +]); + +/** + * Plugin name charset accepted by the promote command. Mirrors npm package + * naming (lowercase, dashes, underscores, dots, optional @scope/) and explicitly + * forbids path separators, traversal, and NUL — so the name cannot escape its + * intended directory when used in path.join(). + */ +const PLUGIN_NAME_PATTERN = + /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i; + +function validatePluginName(pluginName: string): void { + if (!pluginName || pluginName.includes("\0") || pluginName.includes("..")) { + throw new Error( + `Invalid plugin name "${pluginName}". Plugin names must not contain "..", or null bytes.`, + ); + } + // Backslash is never allowed (treated as a path separator on Windows). + if (pluginName.includes("\\")) { + throw new Error( + `Invalid plugin name "${pluginName}". Plugin names must not contain backslashes.`, + ); + } + // Forward slash is only allowed inside the @scope/name form. + if (pluginName.includes("/") && !pluginName.startsWith("@")) { + throw new Error( + `Invalid plugin name "${pluginName}". Plugin names must not contain "/" unless they are a scoped package (e.g. @scope/name).`, + ); + } + if (!PLUGIN_NAME_PATTERN.test(pluginName)) { + throw new Error( + `Invalid plugin name "${pluginName}". Expected lowercase alphanumeric with optional dashes, underscores, dots, or @scope/ prefix.`, + ); + } +} + +function isStability(value: unknown): value is Stability { + return value === "experimental" || value === "preview" || value === "stable"; } function findPluginManifest( @@ -44,12 +93,21 @@ function findPluginManifest( if (result) return { manifestPath: result, isLocal: true }; } + // node_modules fallback. Validate and bound to the dist/plugins subtree to + // make sure a malicious or typo'd name cannot escape via path.join. 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)) { + const manifestPath = path.resolve( + pluginsDir, + pluginName, + "manifest.json", + ); + if ( + isWithinDirectory(manifestPath, pluginsDir) && + fs.existsSync(manifestPath) + ) { return { manifestPath, isLocal: false }; } } @@ -64,12 +122,20 @@ function scanDirForPlugin( cwd: string, depth: number, ): string | null { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null; if (depth >= MAX_SCAN_DEPTH) return null; + let stat: fs.Stats; + try { + stat = fs.statSync(dir); + } catch { + return null; + } + if (!stat.isDirectory()) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; + if (SKIP_DIRECTORIES.has(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const childPath = path.join(dir, entry.name); const allowJs = shouldAllowJsManifestForDir(childPath); const resolved = resolveManifestInDir(childPath, { @@ -79,13 +145,16 @@ function scanDirForPlugin( 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; - } + if ( + obj && + typeof obj === "object" && + "name" in obj && + (obj as { name: string }).name === pluginName + ) { + return resolved.path; } } catch { - // skip + // skip unreadable / invalid manifest } continue; } @@ -139,23 +208,26 @@ function rewriteImportsInFile( }; } -function findTsFiles(dir: string, depth = 0): string[] { - if (depth >= 10) return []; - if (!fs.existsSync(dir)) return []; +function findTsFiles(dir: string, projectRoot: string, depth = 0): string[] { + if (depth >= MAX_SCAN_DEPTH) return []; const results: string[] = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } for (const entry of entries) { const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { - if ( - entry.name === "node_modules" || - entry.name === "dist" || - entry.name === ".git" - ) - continue; - results.push(...findTsFiles(fullPath, depth + 1)); + if (SKIP_DIRECTORIES.has(entry.name)) continue; + // Boundary check to ensure recursion stays inside the project root, + // even if a future change introduces a symlink-following path. + if (!isWithinDirectory(fullPath, projectRoot)) continue; + results.push(...findTsFiles(fullPath, projectRoot, depth + 1)); } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { results.push(fullPath); } @@ -171,29 +243,66 @@ async function runPromote( dryRun?: boolean; skipImports?: boolean; skipSync?: boolean; + allowInstalled?: boolean; }, ): Promise { + validatePluginName(pluginName); + const cwd = process.cwd(); - const target = options.to as Stability; - if (!["experimental", "preview", "stable"].includes(target)) { + if (!isStability(options.to)) { console.error( - `Invalid target tier "${target}". Must be one of: experimental, preview, stable`, + `Invalid target tier "${options.to}". Must be one of: experimental, preview, stable.`, ); process.exit(1); } + const target: Stability = options.to; const found = findPluginManifest(pluginName, cwd); if (!found) { console.error( - `Plugin "${pluginName}" not found. Searched local dirs and node_modules.`, + `Plugin "${pluginName}" not found. Searched local dirs (plugins, server, .) and node_modules.`, + ); + process.exit(1); + } + + const { manifestPath, isLocal } = found; + + if (!isLocal && !options.allowInstalled) { + console.error( + `Plugin "${pluginName}" was only found under node_modules (${path.relative(cwd, manifestPath)}).\n` + + `Refusing to mutate an installed package — re-install would overwrite the change.\n` + + `Pass --allow-installed to override (advanced; not recommended).`, + ); + process.exit(1); + } + + let raw: Record; + try { + const parsed = loadManifestFromFileSync(manifestPath); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + console.error( + `Manifest at ${path.relative(cwd, manifestPath)} is not a JSON object.`, + ); + process.exit(1); + } + raw = parsed as Record; + } catch (err) { + console.error( + `Failed to read manifest at ${path.relative(cwd, manifestPath)}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } - const { manifestPath } = found; - const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - const currentStability: Stability = raw.stability ?? "stable"; + const rawStability = raw.stability ?? "stable"; + if (!isStability(rawStability)) { + console.error( + `Manifest at ${path.relative(cwd, manifestPath)} has an invalid stability value "${String(rawStability)}". ` + + `Must be one of: experimental, preview, stable (or omitted for stable).`, + ); + process.exit(1); + } + const currentStability: Stability = rawStability; if (currentStability === target) { console.error( @@ -211,7 +320,6 @@ async function runPromote( const prefix = options.dryRun ? "[dry-run] " : ""; - // Update manifest if (target === "stable") { delete raw.stability; } else { @@ -225,13 +333,12 @@ async function runPromote( `${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); + const tsFiles = findTsFiles(cwd, cwd); for (const file of tsFiles) { const result = rewriteImportsInFile( file, @@ -252,7 +359,6 @@ async function runPromote( } } - // Auto-sync if (!options.skipSync && !options.dryRun) { console.log(`\n${prefix}Running plugin sync...`); const { execSync } = await import("node:child_process"); @@ -262,7 +368,11 @@ async function runPromote( stdio: "inherit", }); } catch { - console.warn("Warning: plugin sync failed. Run manually."); + console.error( + `Error: post-promote 'plugin sync' failed. Manifest and imports were updated, ` + + `but appkit.plugins.json may be out of sync. Run 'npx appkit plugin sync --write' manually.`, + ); + process.exit(1); } } @@ -274,6 +384,18 @@ async function runPromote( } } +/** Exported for testing. */ +export { + PLUGIN_NAME_PATTERN, + TIER_ORDER, + IMPORT_PATH_MAP, + SKIP_DIRECTORIES, + isStability, + validatePluginName, + rewriteImportsInFile, + runPromote, +}; + export const pluginPromoteCommand = new Command("promote") .description("Promote a plugin to a higher stability tier") .argument("", "Plugin name to promote") @@ -284,9 +406,15 @@ export const pluginPromoteCommand = new Command("promote") .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") + .option( + "--allow-installed", + "Allow promoting a plugin that lives only under node_modules (advanced)", + ) .action((pluginName, opts) => runPromote(pluginName, opts).catch((err) => { - console.error(err); + const message = err instanceof Error ? err.message : String(err); + console.error(`Error: ${message}`); + if (process.env.DEBUG) 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 1c1741317..36edd948c 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -790,15 +790,16 @@ async function runPluginsSync(options: { const newNames = new Set(Object.keys(plugins)); for (const name of oldNames) { if (newNames.has(name)) continue; - const oldPlugin = oldManifest.plugins[name]; + const oldPlugin = oldManifest.plugins?.[name]; + if (!oldPlugin || typeof oldPlugin !== "object") continue; const envVars: string[] = []; for (const res of [ ...(oldPlugin.resources?.required ?? []), ...(oldPlugin.resources?.optional ?? []), ]) { - if (res.fields) { + if (res?.fields) { for (const field of Object.values(res.fields)) { - if (field.env) envVars.push(field.env); + if (field?.env) envVars.push(field.env); } } } From 8cba0d0b4965b8556581d54464e2bec17b34b55f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 27 Apr 2026 12:01:57 +0200 Subject: [PATCH 03/17] docs: regenerate plugin-manifest schema & typedoc for stability field Re-running 'pnpm docs:build' picks up three derived artifacts that the original feature commit missed: - docs/docs/api/appkit/Interface.PluginManifest.md (typedoc): adds the new optional 'stability' field on PluginManifest - docs/static/schemas/plugin-manifest.schema.json: adds the experimental/preview/stable enum (default "stable") - docs/static/schemas/template-plugins.schema.json: adds 'stability' and bumps schema version from const "1.0" to enum ["1.0","1.1"] Generated by 'pnpm docs:build'; no source changes. Signed-off-by: MarioCadenas --- docs/docs/api/appkit/Interface.PluginManifest.md | 16 ++++++++++++++++ docs/static/schemas/plugin-manifest.schema.json | 6 ++++++ docs/static/schemas/template-plugins.schema.json | 8 +++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 84ff24870..a60db6e56 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -213,6 +213,22 @@ Resources that must be available for the plugin to function *** +### stability? + +```ts +optional stability: "experimental" | "preview" | "stable"; +``` + +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. + +#### Inherited from + +```ts +Omit.stability +``` + +*** + ### version? ```ts diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index ed4ef5731..20bdbdc3d 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/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/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 290edd059..689a6f0a9 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -12,7 +12,7 @@ }, "version": { "type": "string", - "const": "1.0", + "enum": ["1.0", "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"], From 9eb2bc61f439126f516a4cf203946e6b3b51c7d7 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 27 Apr 2026 19:27:06 +0200 Subject: [PATCH 04/17] refactor(plugin): collapse stability tiers to beta/stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the "experimental" tier entirely and renames "preview" to "beta". The stability system now has two tiers instead of three: beta ──→ stable Beta plugins follow the previous Preview contract: APIs may change between minor releases, but the plugin is on a path to stable. Changes: - Schema: plugin-manifest and template-plugins enums updated to ["beta", "stable"]; manifest schema version stays at 1.1. - Subpath exports: drop @databricks/appkit/experimental and @databricks/appkit-ui/{js,react}/experimental; rename /preview to /beta. Source entry files renamed via git mv (experimental.ts files removed). - Promote command: TIER_ORDER, IMPORT_PATH_MAP, isStability(), help text, and tests updated. Legacy tier values are now rejected both as --to targets and as manifest stability values; tests assert this. - Create command: stability prompt offers Stable | Beta only. - list/sync/CreateAnswers types narrowed to "beta" | "stable". - Docs: stability.md rewritten for the two-tier system; auto-generated Interface.PluginManifest.md and docs/static/schemas/* match the new enum after pnpm docs:build. Signed-off-by: MarioCadenas --- .../api/appkit/Interface.PluginManifest.md | 4 +- docs/docs/plugins/stability.md | 40 ++--- .../schemas/plugin-manifest.schema.json | 4 +- .../schemas/template-plugins.schema.json | 4 +- packages/appkit-ui/package.json | 26 +--- .../appkit-ui/src/js/{preview.ts => beta.ts} | 2 +- packages/appkit-ui/src/js/experimental.ts | 2 - .../src/react/{preview.ts => beta.ts} | 2 +- packages/appkit-ui/src/react/experimental.ts | 2 - packages/appkit-ui/tsdown.config.ts | 6 +- packages/appkit/package.json | 13 +- packages/appkit/src/{preview.ts => beta.ts} | 2 +- packages/appkit/src/experimental.ts | 3 - packages/appkit/tsdown.config.ts | 2 +- .../src/cli/commands/plugin/create/create.ts | 13 +- .../commands/plugin/create/scaffold.test.ts | 23 +-- .../src/cli/commands/plugin/create/types.ts | 2 +- .../shared/src/cli/commands/plugin/index.ts | 2 +- .../src/cli/commands/plugin/list/list.test.ts | 29 ++-- .../src/cli/commands/plugin/list/list.ts | 6 +- .../src/cli/commands/plugin/manifest-types.ts | 2 +- .../commands/plugin/promote/promote.test.ts | 138 ++++++++---------- .../cli/commands/plugin/promote/promote.ts | 21 +-- .../src/cli/commands/plugin/sync/sync.ts | 2 +- .../src/schemas/plugin-manifest.generated.ts | 4 +- .../src/schemas/plugin-manifest.schema.json | 4 +- .../src/schemas/template-plugins.schema.json | 4 +- 27 files changed, 137 insertions(+), 225 deletions(-) rename packages/appkit-ui/src/js/{preview.ts => beta.ts} (50%) delete mode 100644 packages/appkit-ui/src/js/experimental.ts rename packages/appkit-ui/src/react/{preview.ts => beta.ts} (50%) delete mode 100644 packages/appkit-ui/src/react/experimental.ts rename packages/appkit/src/{preview.ts => beta.ts} (67%) delete mode 100644 packages/appkit/src/experimental.ts diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index a60db6e56..edee19bc5 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -216,10 +216,10 @@ Resources that must be available for the plugin to function ### stability? ```ts -optional stability: "experimental" | "preview" | "stable"; +optional stability: "beta" | "stable"; ``` -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. +Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly. #### Inherited from diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md index d3d895bcb..d9efbc2b8 100644 --- a/docs/docs/plugins/stability.md +++ b/docs/docs/plugins/stability.md @@ -4,26 +4,23 @@ sidebar_position: 2 # Plugin Stability Tiers -AppKit plugins have a three-tier stability system that communicates API maturity and breaking-change expectations. +AppKit plugins have a two-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. | +| **Beta** | `@databricks/appkit/beta` | 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. +The import path is the primary stability signal. Importing from `/beta` is explicit consent to potential breaking changes. ## Promotion Path Promotion is one-way. Plugins can enter at any tier. ``` -experimental ──→ preview ──→ stable - │ - └──→ (dropped) +beta ──→ stable ``` ## Usage @@ -34,11 +31,8 @@ experimental ──→ preview ──→ stable // Stable plugins import { server, analytics } from "@databricks/appkit"; -// Preview plugins -import { somePreviewPlugin } from "@databricks/appkit/preview"; - -// Experimental plugins -import { someExperimentalPlugin } from "@databricks/appkit/experimental"; +// Beta plugins +import { someBetaPlugin } from "@databricks/appkit/beta"; ``` ### UI Components @@ -46,8 +40,8 @@ import { someExperimentalPlugin } from "@databricks/appkit/experimental"; `@databricks/appkit-ui` mirrors the same pattern: ```typescript -import { SomeComponent } from "@databricks/appkit-ui/react/preview"; -import { someUtil } from "@databricks/appkit-ui/js/experimental"; +import { SomeComponent } from "@databricks/appkit-ui/react/beta"; +import { someUtil } from "@databricks/appkit-ui/js/beta"; ``` ## CLI Commands @@ -71,14 +65,11 @@ 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 +# Promote from beta 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 +npx appkit plugin promote my-plugin --to stable --dry-run ``` The promote command: @@ -90,6 +81,7 @@ The promote command: - `--dry-run` -- Show what would change without writing - `--skip-imports` -- Only update the manifest - `--skip-sync` -- Don't auto-run sync +- `--allow-installed` -- Allow promoting a plugin that lives only under `node_modules` (advanced) ## Manifest Field @@ -99,13 +91,13 @@ The `stability` field in `manifest.json` is optional. When absent, the plugin is { "name": "my-plugin", "displayName": "My Plugin", - "description": "An experimental feature", - "stability": "experimental", + "description": "An in-development feature", + "stability": "beta", "resources": { "required": [], "optional": [] } } ``` -Valid values: `"experimental"`, `"preview"`, `"stable"`. +Valid values: `"beta"`, `"stable"`. ## Template Manifest (appkit.plugins.json) @@ -117,7 +109,7 @@ When `plugin sync` discovers non-stable plugins, it includes their stability in "plugins": { "my-plugin": { "name": "my-plugin", - "stability": "experimental", + "stability": "beta", "package": "@databricks/appkit" } } @@ -128,7 +120,7 @@ Only stable plugins can be marked `requiredByTemplate`. Non-stable plugins alway ## 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. +The import path (`/beta`) only applies 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 diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 20bdbdc3d..42467f8f3 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -98,9 +98,9 @@ }, "stability": { "type": "string", - "enum": ["experimental", "preview", "stable"], + "enum": ["beta", "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." + "description": "Plugin stability level. Beta 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/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 689a6f0a9..5bce051da 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -71,9 +71,9 @@ }, "stability": { "type": "string", - "enum": ["experimental", "preview", "stable"], + "enum": ["beta", "stable"], "default": "stable", - "description": "Plugin stability level. Experimental may be dropped. Preview is heading to stable. Stable follows semver." + "description": "Plugin stability level. Beta is heading to stable; APIs may change between minor releases. Stable follows semver." }, "resources": { "type": "object", diff --git a/packages/appkit-ui/package.json b/packages/appkit-ui/package.json index 06d205c0f..c8ed07803 100644 --- a/packages/appkit-ui/package.json +++ b/packages/appkit-ui/package.json @@ -27,25 +27,17 @@ "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" + "./js/beta": { + "development": "./src/js/beta.ts", + "default": "./dist/js/beta.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" + "./react/beta": { + "development": "./src/react/beta.ts", + "default": "./dist/react/beta.js" }, "./package.json": "./package.json", "./styles.css": { @@ -127,11 +119,9 @@ "publishConfig": { "exports": { "./js": "./dist/js/index.js", - "./js/experimental": "./dist/js/experimental.js", - "./js/preview": "./dist/js/preview.js", + "./js/beta": "./dist/js/beta.js", "./react": "./dist/react/index.js", - "./react/experimental": "./dist/react/experimental.js", - "./react/preview": "./dist/react/preview.js", + "./react/beta": "./dist/react/beta.js", "./package.json": "./package.json", "./styles.css": "./dist/styles.css" } diff --git a/packages/appkit-ui/src/js/preview.ts b/packages/appkit-ui/src/js/beta.ts similarity index 50% rename from packages/appkit-ui/src/js/preview.ts rename to packages/appkit-ui/src/js/beta.ts index a407db4a0..088ff80b1 100644 --- a/packages/appkit-ui/src/js/preview.ts +++ b/packages/appkit-ui/src/js/beta.ts @@ -1,2 +1,2 @@ -// Preview JS utilities -- APIs may change between minor releases. +// Beta 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/js/experimental.ts b/packages/appkit-ui/src/js/experimental.ts deleted file mode 100644 index 8b2205b36..000000000 --- a/packages/appkit-ui/src/js/experimental.ts +++ /dev/null @@ -1,2 +0,0 @@ -// 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/react/preview.ts b/packages/appkit-ui/src/react/beta.ts similarity index 50% rename from packages/appkit-ui/src/react/preview.ts rename to packages/appkit-ui/src/react/beta.ts index ef777f6ca..0405e50de 100644 --- a/packages/appkit-ui/src/react/preview.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -1,2 +1,2 @@ -// Preview React components -- APIs may change between minor releases. +// Beta React components -- APIs may change between minor releases. // Import from '@databricks/appkit-ui/react' once graduated to stable. diff --git a/packages/appkit-ui/src/react/experimental.ts b/packages/appkit-ui/src/react/experimental.ts deleted file mode 100644 index e88223001..000000000 --- a/packages/appkit-ui/src/react/experimental.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Experimental React components -- very unstable, may be dropped entirely. -// Import from '@databricks/appkit-ui/react/preview' once promoted. diff --git a/packages/appkit-ui/tsdown.config.ts b/packages/appkit-ui/tsdown.config.ts index b60acc6a5..f55a51457 100644 --- a/packages/appkit-ui/tsdown.config.ts +++ b/packages/appkit-ui/tsdown.config.ts @@ -6,11 +6,9 @@ export default defineConfig([ name: "@databricks/appkit-ui", entry: [ "src/js/index.ts", - "src/js/experimental.ts", - "src/js/preview.ts", + "src/js/beta.ts", "src/react/index.ts", - "src/react/experimental.ts", - "src/react/preview.ts", + "src/react/beta.ts", ], outDir: "dist", platform: "browser", diff --git a/packages/appkit/package.json b/packages/appkit/package.json index e3bc9d979..7e204c4f6 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -29,13 +29,9 @@ "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" + "./beta": { + "development": "./src/beta.ts", + "default": "./dist/beta.js" }, "./type-generator": { "types": "./dist/type-generator/index.d.ts", @@ -103,8 +99,7 @@ "publishConfig": { "exports": { ".": "./dist/index.js", - "./experimental": "./dist/experimental.js", - "./preview": "./dist/preview.js", + "./beta": "./dist/beta.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/preview.ts b/packages/appkit/src/beta.ts similarity index 67% rename from packages/appkit/src/preview.ts rename to packages/appkit/src/beta.ts index 0af9abc96..aca7fc245 100644 --- a/packages/appkit/src/preview.ts +++ b/packages/appkit/src/beta.ts @@ -1,3 +1,3 @@ -// Preview plugins -- APIs may change between minor releases. +// Beta 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/src/experimental.ts b/packages/appkit/src/experimental.ts deleted file mode 100644 index ac4095342..000000000 --- a/packages/appkit/src/experimental.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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/tsdown.config.ts b/packages/appkit/tsdown.config.ts index ef38c13b3..d61e8c534 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", "src/experimental.ts", "src/preview.ts"], + entry: ["src/index.ts", "src/beta.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 6e558a1cb..91e2535ca 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -333,22 +333,17 @@ async function runInteractive(): Promise { process.exit(0); } - const stability = await select<"stable" | "preview" | "experimental">({ + const stability = await select<"stable" | "beta">({ message: "Plugin stability level", options: [ { value: "stable", label: "Stable", hint: "API follows semver" }, { - value: "preview", - label: "Preview", + value: "beta", + label: "Beta", hint: "Heading to stable, API may change", }, - { - value: "experimental", - label: "Experimental", - hint: "Very unstable, may be dropped", - }, ], - initialValue: "stable" as "stable" | "preview" | "experimental", + initialValue: "stable" as "stable" | "beta", }); if (isCancel(stability)) { cancel("Cancelled."); 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 8b5d0a3e5..e5d384c22 100644 --- a/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts @@ -236,38 +236,21 @@ describe("scaffold", () => { expect(manifest.stability).toBeUndefined(); }); - it('includes stability: "preview" when set', () => { + it('includes stability: "beta" when set', () => { const tmp = makeTempDir(); tempDirs.push(tmp); const targetDir = path.join(tmp, "test"); scaffoldPlugin( targetDir, - { ...BASE_ANSWERS, stability: "preview" }, + { ...BASE_ANSWERS, stability: "beta" }, { 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"); + expect(manifest.stability).toBe("beta"); }); }); diff --git a/packages/shared/src/cli/commands/plugin/create/types.ts b/packages/shared/src/cli/commands/plugin/create/types.ts index 5cd78b9d2..518dc4e38 100644 --- a/packages/shared/src/cli/commands/plugin/create/types.ts +++ b/packages/shared/src/cli/commands/plugin/create/types.ts @@ -23,7 +23,7 @@ export interface CreateAnswers { displayName: string; description: string; /** Only set when non-stable. Absent = "stable" (default). */ - stability?: "experimental" | "preview"; + stability?: "beta"; 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 165d1a583..cc5ab6994 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -33,5 +33,5 @@ Examples: $ appkit plugin validate . $ appkit plugin list --json $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse - $ appkit plugin promote my-plugin --to preview`, + $ appkit plugin promote my-plugin --to stable`, ); 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 edbdb97df..f89775500 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.test.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -143,18 +143,11 @@ describe("list", () => { version: "1.1", plugins: { ...TEMPLATE_MANIFEST_JSON.plugins, - preview: { - name: "preview-plugin", - displayName: "Preview Plugin", + beta: { + name: "beta-plugin", + displayName: "Beta Plugin", package: "@databricks/appkit", - stability: "preview", - resources: { required: [], optional: [] }, - }, - experimental: { - name: "exp-plugin", - displayName: "Experimental Plugin", - package: "@databricks/appkit", - stability: "experimental", + stability: "beta", resources: { required: [], optional: [] }, }, }, @@ -163,12 +156,10 @@ describe("list", () => { 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 betaRow = rows.find((r) => r.name === "beta-plugin"); const stableRow = rows.find((r) => r.name === "server"); - expect(previewRow?.stability).toBe("preview"); - expect(expRow?.stability).toBe("experimental"); + expect(betaRow?.stability).toBe("beta"); expect(stableRow?.stability).toBe("stable"); }); }); @@ -227,21 +218,21 @@ describe("list", () => { 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"); + const pluginDir = path.join(tmp, "beta-plugin"); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync( path.join(pluginDir, "manifest.json"), JSON.stringify({ ...PLUGIN_MANIFEST_JSON, - name: "preview-feature", - stability: "preview", + name: "beta-feature", + stability: "beta", }), ); const rows = await listFromDirectory(tmp, path.dirname(tmp)); expect(rows).toHaveLength(1); - expect(rows[0].stability).toBe("preview"); + expect(rows[0].stability).toBe("beta"); }); it("defaults stability to stable in directory scan when absent", async () => { diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index b0928de26..cc1000d92 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -16,7 +16,7 @@ export interface PluginRow { name: string; displayName: string; package: string; - stability: "experimental" | "preview" | "stable"; + stability: "beta" | "stable"; required: number; optional: number; } @@ -37,7 +37,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: string; displayName: string; package: string; - stability?: "experimental" | "preview" | "stable"; + stability?: "beta" | "stable"; resources: { required: unknown[]; optional: unknown[] }; } >; @@ -107,7 +107,7 @@ async function collectPluginsRecursive( ? relPath : `./${relPath}`; const rawManifest = manifest as typeof manifest & { - stability?: "experimental" | "preview" | "stable"; + stability?: "beta" | "stable"; }; rows.push({ name: manifest.name, diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index b0aab1a95..8b21917d5 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -18,7 +18,7 @@ export interface TemplatePlugin extends Omit { /** 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"; + stability?: "beta" | "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 index 1cc21409c..951d98881 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -123,13 +123,14 @@ describe("validatePluginName", () => { }); describe("isStability", () => { - it("accepts the three tiers", () => { - expect(isStability("experimental")).toBe(true); - expect(isStability("preview")).toBe(true); + it("accepts the two tiers", () => { + expect(isStability("beta")).toBe(true); expect(isStability("stable")).toBe(true); }); - it("rejects everything else", () => { + it("rejects everything else (including legacy tiers)", () => { + expect(isStability("experimental")).toBe(false); + expect(isStability("preview")).toBe(false); expect(isStability("Stable")).toBe(false); expect(isStability("STABLE")).toBe(false); expect(isStability("alpha")).toBe(false); @@ -140,15 +141,13 @@ describe("isStability", () => { }); describe("TIER_ORDER", () => { - it("orders experimental < preview < stable", () => { - expect(TIER_ORDER.experimental).toBeLessThan(TIER_ORDER.preview); - expect(TIER_ORDER.preview).toBeLessThan(TIER_ORDER.stable); + it("orders beta < stable", () => { + expect(TIER_ORDER.beta).toBeLessThan(TIER_ORDER.stable); }); it("IMPORT_PATH_MAP returns empty string for stable (root entrypoint)", () => { expect(IMPORT_PATH_MAP.stable).toBe(""); - expect(IMPORT_PATH_MAP.experimental).toBe("/experimental"); - expect(IMPORT_PATH_MAP.preview).toBe("/preview"); + expect(IMPORT_PATH_MAP.beta).toBe("/beta"); }); }); @@ -165,32 +164,18 @@ describe("rewriteImportsInFile", () => { it("rewrites a matching import", () => { const file = path.join(tmp, "server.ts"); - fs.writeFileSync( - file, - `import { x } from "@databricks/appkit/experimental";\n`, - ); - const result = rewriteImportsInFile( - file, - "/experimental", - "/preview", - false, - ); + fs.writeFileSync(file, `import { x } from "@databricks/appkit/beta";\n`); + const result = rewriteImportsInFile(file, "/beta", "", false); expect(result).not.toBeNull(); - expect(fs.readFileSync(file, "utf-8")).toContain( - "@databricks/appkit/preview", - ); + expect(fs.readFileSync(file, "utf-8")).toContain(`"@databricks/appkit"`); + expect(fs.readFileSync(file, "utf-8")).not.toContain("/beta"); }); it("dry-run does not write the file", () => { const file = path.join(tmp, "server.ts"); - const original = `import { x } from "@databricks/appkit/experimental";\n`; + const original = `import { x } from "@databricks/appkit/beta";\n`; fs.writeFileSync(file, original); - const result = rewriteImportsInFile( - file, - "/experimental", - "/preview", - true, - ); + const result = rewriteImportsInFile(file, "/beta", "", true); expect(result).not.toBeNull(); expect(fs.readFileSync(file, "utf-8")).toBe(original); }); @@ -198,12 +183,7 @@ describe("rewriteImportsInFile", () => { it("returns null when no rewrite is needed", () => { const file = path.join(tmp, "server.ts"); fs.writeFileSync(file, `import { x } from "express";\n`); - const result = rewriteImportsInFile( - file, - "/experimental", - "/preview", - false, - ); + const result = rewriteImportsInFile(file, "/beta", "", false); expect(result).toBeNull(); }); }); @@ -221,21 +201,32 @@ describe("runPromote", () => { it("rejects an invalid plugin name (path traversal attempt)", async () => { await expect( - runPromote("../etc/passwd", { to: "preview", skipSync: true }), + runPromote("../etc/passwd", { to: "stable", skipSync: true }), ).rejects.toThrow(/Invalid plugin name/); }); it("rejects an invalid target tier", async () => { - writeManifest(h.cwd, "my-plugin", "experimental"); + writeManifest(h.cwd, "my-plugin", "beta"); await expect( runPromote("my-plugin", { to: "ALPHA", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /Invalid target tier/.test(e))).toBe(true); }); + it("rejects legacy tier names as targets", async () => { + writeManifest(h.cwd, "my-plugin", "beta"); + for (const legacy of ["experimental", "preview"]) { + h.errors.length = 0; + await expect( + runPromote("my-plugin", { to: legacy, skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /Invalid target tier/.test(e))).toBe(true); + } + }); + it("rejects when plugin is not found", async () => { await expect( - runPromote("ghost", { to: "preview", skipSync: true }), + runPromote("ghost", { to: "stable", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /not found/.test(e))).toBe(true); }); @@ -243,48 +234,37 @@ describe("runPromote", () => { it("rejects an invalid stability value in the manifest (cased 'Stable')", async () => { writeManifest(h.cwd, "my-plugin", "Stable"); await expect( - runPromote("my-plugin", { to: "preview", skipSync: true }), + runPromote("my-plugin", { to: "stable", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe(true); }); - it("rejects demotion when manifest is at a higher valid tier", async () => { + it("rejects a legacy stability value in the manifest", async () => { writeManifest(h.cwd, "my-plugin", "preview"); await expect( - runPromote("my-plugin", { to: "experimental", skipSync: true }), + runPromote("my-plugin", { to: "stable", skipSync: true }), ).rejects.toThrow(/__exit:1/); - expect(h.errors.some((e) => /Cannot demote/.test(e))).toBe(true); + expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe(true); }); - it("rejects demotion from stable (absent stability) to preview", async () => { + it("rejects demotion from stable (absent stability) to beta", async () => { writeManifest(h.cwd, "my-plugin"); await expect( - runPromote("my-plugin", { to: "preview", skipSync: true }), + runPromote("my-plugin", { to: "beta", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /Cannot demote/.test(e))).toBe(true); }); it("rejects no-op (already at target)", async () => { - writeManifest(h.cwd, "my-plugin", "preview"); + writeManifest(h.cwd, "my-plugin", "beta"); await expect( - runPromote("my-plugin", { to: "preview", skipSync: true }), + runPromote("my-plugin", { to: "beta", skipSync: true }), ).rejects.toThrow(/__exit:1/); - expect(h.errors.some((e) => /already at "preview"/.test(e))).toBe(true); - }); - - it("promotes experimental to preview and updates manifest", async () => { - const manifestPath = writeManifest(h.cwd, "my-plugin", "experimental"); - await runPromote("my-plugin", { - to: "preview", - skipSync: true, - skipImports: true, - }); - const updated = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(updated.stability).toBe("preview"); + expect(h.errors.some((e) => /already at "beta"/.test(e))).toBe(true); }); - it("promotes preview to stable by removing the stability field", async () => { - const manifestPath = writeManifest(h.cwd, "my-plugin", "preview"); + it("promotes beta to stable by removing the stability field", async () => { + const manifestPath = writeManifest(h.cwd, "my-plugin", "beta"); await runPromote("my-plugin", { to: "stable", skipSync: true, @@ -295,10 +275,10 @@ describe("runPromote", () => { }); it("dry-run does not mutate the manifest", async () => { - const manifestPath = writeManifest(h.cwd, "my-plugin", "experimental"); + const manifestPath = writeManifest(h.cwd, "my-plugin", "beta"); const before = fs.readFileSync(manifestPath, "utf-8"); await runPromote("my-plugin", { - to: "preview", + to: "stable", dryRun: true, skipSync: true, skipImports: true, @@ -307,38 +287,38 @@ describe("runPromote", () => { }); it("rewrites imports across .ts and .tsx files in the project", async () => { - writeManifest(h.cwd, "my-plugin", "experimental"); + writeManifest(h.cwd, "my-plugin", "beta"); const tsFile = path.join(h.cwd, "server", "server.ts"); fs.mkdirSync(path.dirname(tsFile), { recursive: true }); fs.writeFileSync( tsFile, - `import { myPlugin } from "@databricks/appkit/experimental";\n`, + `import { myPlugin } from "@databricks/appkit/beta";\n`, ); const tsxFile = path.join(h.cwd, "client", "App.tsx"); fs.mkdirSync(path.dirname(tsxFile), { recursive: true }); fs.writeFileSync( tsxFile, - `import { Comp } from "@databricks/appkit-ui/react/experimental";\n`, + `import { Comp } from "@databricks/appkit-ui/react/beta";\n`, ); - await runPromote("my-plugin", { to: "preview", skipSync: true }); + await runPromote("my-plugin", { to: "stable", skipSync: true }); - expect(fs.readFileSync(tsFile, "utf-8")).toContain( - "@databricks/appkit/preview", - ); + expect(fs.readFileSync(tsFile, "utf-8")).toContain(`"@databricks/appkit"`); + expect(fs.readFileSync(tsFile, "utf-8")).not.toContain("/beta"); expect(fs.readFileSync(tsxFile, "utf-8")).toContain( - "@databricks/appkit-ui/react/preview", + `"@databricks/appkit-ui/react"`, ); + expect(fs.readFileSync(tsxFile, "utf-8")).not.toContain("/beta"); }); it("skips symlinked directories during the project walk", async () => { - writeManifest(h.cwd, "my-plugin", "experimental"); + writeManifest(h.cwd, "my-plugin", "beta"); const realOutside = makeTempDir(); try { const outsideTs = path.join(realOutside, "leak.ts"); fs.writeFileSync( outsideTs, - `import { x } from "@databricks/appkit/experimental";\n`, + `import { x } from "@databricks/appkit/beta";\n`, ); const link = path.join(h.cwd, "linked"); try { @@ -347,10 +327,10 @@ describe("runPromote", () => { return; } - await runPromote("my-plugin", { to: "preview", skipSync: true }); + await runPromote("my-plugin", { to: "stable", skipSync: true }); expect(fs.readFileSync(outsideTs, "utf-8")).toContain( - "@databricks/appkit/experimental", + "@databricks/appkit/beta", ); } finally { cleanDir(realOutside); @@ -361,12 +341,12 @@ describe("runPromote", () => { const manifestPath = writeManifest( h.cwd, "installed-plugin", - "experimental", + "beta", "node_modules/@databricks/appkit/dist/plugins", ); await expect( runPromote("installed-plugin", { - to: "preview", + to: "stable", skipSync: true, skipImports: true, }), @@ -375,7 +355,7 @@ describe("runPromote", () => { h.errors.some((e) => /Refusing to mutate an installed package/.test(e)), ).toBe(true); const after = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - expect(after.stability).toBe("experimental"); + expect(after.stability).toBe("beta"); }); it("rejects a path-traversal name even if a manifest exists outside node_modules/dist/plugins", async () => { @@ -386,10 +366,10 @@ describe("runPromote", () => { fs.mkdirSync(path.join(h.cwd, "elsewhere"), { recursive: true }); fs.writeFileSync( path.join(h.cwd, "elsewhere", "manifest.json"), - JSON.stringify({ name: "ghost", stability: "experimental" }), + JSON.stringify({ name: "ghost", stability: "beta" }), ); await expect( - runPromote("../../../elsewhere", { to: "preview", skipSync: true }), + runPromote("../../../elsewhere", { to: "stable", skipSync: true }), ).rejects.toThrow(/Invalid plugin name/); }); }); diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index fd17da8df..cd51f9b63 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -6,17 +6,15 @@ import { resolveManifestInDir } from "../manifest-resolve"; import { isWithinDirectory } from "../sync/sync"; import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; -type Stability = "experimental" | "preview" | "stable"; +type Stability = "beta" | "stable"; const TIER_ORDER: Record = { - experimental: 0, - preview: 1, - stable: 2, + beta: 0, + stable: 1, }; const IMPORT_PATH_MAP: Record = { - experimental: "/experimental", - preview: "/preview", + beta: "/beta", stable: "", }; @@ -78,7 +76,7 @@ function validatePluginName(pluginName: string): void { } function isStability(value: unknown): value is Stability { - return value === "experimental" || value === "preview" || value === "stable"; + return value === "beta" || value === "stable"; } function findPluginManifest( @@ -252,7 +250,7 @@ async function runPromote( if (!isStability(options.to)) { console.error( - `Invalid target tier "${options.to}". Must be one of: experimental, preview, stable.`, + `Invalid target tier "${options.to}". Must be one of: beta, stable.`, ); process.exit(1); } @@ -298,7 +296,7 @@ async function runPromote( if (!isStability(rawStability)) { console.error( `Manifest at ${path.relative(cwd, manifestPath)} has an invalid stability value "${String(rawStability)}". ` + - `Must be one of: experimental, preview, stable (or omitted for stable).`, + `Must be one of: beta, stable (or omitted for stable).`, ); process.exit(1); } @@ -399,10 +397,7 @@ export { 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)", - ) + .requiredOption("--to ", "Target stability tier (beta, 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") diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 36edd948c..9697dc2af 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -86,7 +86,7 @@ async function loadPluginEntry( }), ...(manifest.stability && manifest.stability !== "stable" && { - stability: manifest.stability as "experimental" | "preview", + stability: manifest.stability as "beta", }), }, ]; diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts index 90ce52989..9167dcf21 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -215,9 +215,9 @@ export interface PluginManifest { */ 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. + * Plugin stability level. Beta 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"; + stability?: "beta" | "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 20bdbdc3d..42467f8f3 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -98,9 +98,9 @@ }, "stability": { "type": "string", - "enum": ["experimental", "preview", "stable"], + "enum": ["beta", "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." + "description": "Plugin stability level. Beta 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 689a6f0a9..5bce051da 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -71,9 +71,9 @@ }, "stability": { "type": "string", - "enum": ["experimental", "preview", "stable"], + "enum": ["beta", "stable"], "default": "stable", - "description": "Plugin stability level. Experimental may be dropped. Preview is heading to stable. Stable follows semver." + "description": "Plugin stability level. Beta is heading to stable; APIs may change between minor releases. Stable follows semver." }, "resources": { "type": "object", From ed3601f3dbff130ce36287615e3481b495b41dbb Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 12:43:24 +0200 Subject: [PATCH 05/17] fix(template): import beta plugins from /beta subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The init template hard-coded a single `from '@databricks/appkit'` import for every plugin, which would resolve to `undefined` for beta plugins (they live behind the `/beta` subpath export). Splits the import emission by plugin stability: - Stable plugins stay on the existing line. - Each beta plugin gets its own `from '@databricks/appkit/beta'` import line. Backwards-compatible with the current Databricks CLI: until pluginVar exposes Stability, `$p.Stability` resolves to the empty string, every plugin compares unequal to "beta", and rendering matches today's output. Once databricks/cli#5090 lands the field, beta plugins start routing to the right import automatically. The plugins: [...] array body is unchanged — both stable and beta plugins are constructed identically (`plugin()`) once they're in scope, so the runtime call list doesn't depend on tier. Signed-off-by: MarioCadenas --- template/server/server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/template/server/server.ts b/template/server/server.ts index e28f3ef43..f4cb4e061 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,4 +1,9 @@ -import { createApp{{range $name, $_ := .plugins}}, {{$name}}{{end}} } from '@databricks/appkit'; +import { createApp{{range $name, $p := .plugins}}{{if ne $p.Stability "beta"}}, {{$name}}{{end}}{{end}} } from '@databricks/appkit'; +{{- range $name, $p := .plugins}} +{{- if eq $p.Stability "beta"}} +import { {{$name}} } from '@databricks/appkit/beta'; +{{- end}} +{{- end}} {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; {{- end}} From 4b1295d0d14d3405540dd57cb1dad76e9ecfa4f7 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 12:49:34 +0200 Subject: [PATCH 06/17] fix(template): consolidate beta plugin imports into a single line The previous template emitted a separate import line per beta plugin: import { betaA } from '@databricks/appkit/beta'; import { betaB } from '@databricks/appkit/beta'; Switch to a single combined import: import { betaA, betaB } from '@databricks/appkit/beta'; Implementation: a string-accumulator pre-pass over .plugins builds a comma-separated list of beta names using printf, then a single guarded import line is emitted if the list is non-empty. Pure text/template, no sprig functions required, so it works against the Databricks CLI's existing template engine. Behavior unchanged when no beta plugins are selected (no extra import line, identical to all-stable rendering today). Signed-off-by: MarioCadenas --- template/server/server.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/template/server/server.ts b/template/server/server.ts index f4cb4e061..b33bb94d8 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,8 +1,16 @@ +{{- $betaImports := "" -}} +{{- range $name, $p := .plugins -}} + {{- if eq $p.Stability "beta" -}} + {{- if eq $betaImports "" -}} + {{- $betaImports = $name -}} + {{- else -}} + {{- $betaImports = printf "%s, %s" $betaImports $name -}} + {{- end -}} + {{- end -}} +{{- end -}} import { createApp{{range $name, $p := .plugins}}{{if ne $p.Stability "beta"}}, {{$name}}{{end}}{{end}} } from '@databricks/appkit'; -{{- range $name, $p := .plugins}} -{{- if eq $p.Stability "beta"}} -import { {{$name}} } from '@databricks/appkit/beta'; -{{- end}} +{{- if ne $betaImports "" }} +import { {{$betaImports}} } from '@databricks/appkit/beta'; {{- end}} {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; From 5a03918c226a9c3dec9d394af9c251dec0d6dc92 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 16:56:02 +0200 Subject: [PATCH 07/17] feat(appkit): codegen stable/beta export barrels from plugin manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a real gap in the stability system: the manifest's `stability` field, the synced appkit.plugins.json, and the runtime entry exports (src/index.ts, src/beta.ts) were three independent sources of truth that could drift apart. Promoting a plugin via manifest edit alone produced a broken state where the CLI generated `import { x } from "@databricks/appkit/beta"` for a plugin that was still actually exported from `@databricks/appkit`, leaving `x` undefined at runtime. Make the manifest the single source of truth. - New generator at tools/generate-plugin-entries.ts reads every packages/appkit/src/plugins//manifest.json, groups by `stability`, and writes two committed barrels: packages/appkit/src/plugins/stable-exports.generated.ts packages/appkit/src/plugins/beta-exports.generated.ts Hidden plugins (manifest.hidden = true) are skipped, preserving the current vector-search behaviour. - src/index.ts and src/beta.ts now delegate to the generated barrels via `export *`, so adding/moving a plugin only requires editing one file (its manifest). - The hand-curated kitchen-sink barrel at src/plugins/index.ts is removed; the generated barrels replace it and knip flagged it as unused. - Wired into the build: - packages/appkit/package.json#scripts.build:package now runs the generator before tsdown. - Root `generate:types` includes plugin-entries so existing CI "Check generated types are up to date" catches stale barrels. The CI step's git diff list is extended accordingly. - Added a focused `generate:plugin-entries` root script for convenience. - `appkit plugin promote` detects monorepo context (presence of tools/generate-plugin-entries.ts) and re-runs the generator after updating the manifest, before kicking off `plugin sync`. Outside the monorepo (third-party plugin projects) it's a no-op. - docs/docs/plugins/stability.md gains a "For First-Party Plugin Authors" section explaining the single-source-of-truth model and how to flip a plugin between tiers manually. Verified end-to-end: marking genie as `stability: "beta"` → `pnpm run generate:plugin-entries` → genie moves from stable-exports.generated.ts to beta-exports.generated.ts → `pnpm sync:template` → genie carries `stability: "beta"` and no `requiredByTemplate` in appkit.plugins.json. All three layers consistent without manual edits. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 8 +- docs/docs/plugins/stability.md | 20 +++ package.json | 3 +- packages/appkit/package.json | 2 +- packages/appkit/src/beta.ts | 4 + packages/appkit/src/index.ts | 11 +- .../src/plugins/beta-exports.generated.ts | 8 ++ packages/appkit/src/plugins/index.ts | 7 -- .../src/plugins/stable-exports.generated.ts | 13 ++ .../cli/commands/plugin/promote/promote.ts | 23 ++++ tools/generate-plugin-entries.ts | 116 ++++++++++++++++++ 11 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 packages/appkit/src/plugins/beta-exports.generated.ts delete mode 100644 packages/appkit/src/plugins/index.ts create mode 100644 packages/appkit/src/plugins/stable-exports.generated.ts create mode 100644 tools/generate-plugin-entries.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cd0af7dc..ec421fb4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,12 @@ jobs: - name: Check generated types are up to date run: | pnpm run generate:types - if ! git diff --exit-code packages/shared/src/schemas/plugin-manifest.generated.ts packages/appkit/src/registry/types.generated.ts; then - echo "❌ Error: Generated types are out of sync with plugin-manifest.schema.json." + if ! git diff --exit-code \ + packages/shared/src/schemas/plugin-manifest.generated.ts \ + packages/appkit/src/registry/types.generated.ts \ + packages/appkit/src/plugins/stable-exports.generated.ts \ + packages/appkit/src/plugins/beta-exports.generated.ts; then + echo "❌ Error: Generated files are out of sync with their source manifests/schemas." echo "" echo "To fix this:" echo " 1. Run: pnpm run generate:types" diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md index d9efbc2b8..3341526e1 100644 --- a/docs/docs/plugins/stability.md +++ b/docs/docs/plugins/stability.md @@ -122,6 +122,26 @@ Only stable plugins can be marked `requiredByTemplate`. Non-stable plugins alway The import path (`/beta`) only applies 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. +## For First-Party Plugin Authors (AppKit Monorepo) + +Inside the AppKit monorepo, each plugin's `manifest.json` `stability` field is the **single source of truth** for which subpath ships the plugin. A build-time generator (`tools/generate-plugin-entries.ts`) reads every `packages/appkit/src/plugins//manifest.json` and writes: + +- `packages/appkit/src/plugins/stable-exports.generated.ts` — re-exports of stable plugins, included by `src/index.ts` (the `@databricks/appkit` entry). +- `packages/appkit/src/plugins/beta-exports.generated.ts` — re-exports of beta plugins, included by `src/beta.ts` (the `@databricks/appkit/beta` entry). + +These generated barrels are committed and verified by CI; an out-of-date barrel fails the `Check generated types are up to date` step. + +The `appkit plugin promote` command detects monorepo context (presence of `tools/generate-plugin-entries.ts`) and re-runs the generator after updating the manifest, so the runtime exports, the synced `appkit.plugins.json`, and the manifest can never drift apart. + +To move a built-in plugin between tiers manually: + +```bash +# Edit packages/appkit/src/plugins//manifest.json +# Set "stability": "beta" (or remove the field for stable) +pnpm run generate:plugin-entries # regenerate the barrels +pnpm sync:template # regenerate appkit.plugins.json +``` + ## Current Plugins by Tier All built-in plugins are currently **stable**: diff --git a/package.json b/package.json index df8bec590..f9345ab65 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:watch": "pnpm -r --filter=!dev-playground --filter=!docs build:watch", "check:fix": "biome check --write .", "check": "biome check .", - "generate:types": "tsx tools/generate-schema-types.ts && tsx tools/generate-registry-types.ts", + "generate:types": "tsx tools/generate-schema-types.ts && tsx tools/generate-registry-types.ts && tsx tools/generate-plugin-entries.ts && tsx tools/generate-plugin-doc-banners.ts", + "generate:plugin-entries": "tsx tools/generate-plugin-entries.ts", "generate:app-templates": "tsx tools/generate-app-templates.ts", "check:licenses": "tsx tools/check-licenses.ts", "build:notice": "tsx tools/build-notice.ts > NOTICE.md", diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 7e204c4f6..bddc99a8b 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -45,7 +45,7 @@ "./package.json": "./package.json" }, "scripts": { - "build:package": "pnpm exec tsx ../../tools/generate-registry-types.ts && tsdown --config tsdown.config.ts", + "build:package": "pnpm exec tsx ../../tools/generate-registry-types.ts && pnpm exec tsx ../../tools/generate-plugin-entries.ts && tsdown --config tsdown.config.ts", "build:watch": "tsdown --config tsdown.config.ts --watch", "clean:full": "rm -rf dist node_modules tmp", "clean": "rm -rf dist tmp", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index aca7fc245..0e5781c8f 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -1,3 +1,7 @@ // Beta 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. +// +// The exports below are auto-generated from each plugin's manifest.json +// "stability" field. See tools/generate-plugin-entries.ts. +export * from "./plugins/beta-exports.generated"; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index eecda8e3d..c1ab5411e 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -54,15 +54,7 @@ export { type ToPlugin, toPlugin, } from "./plugin"; -export { - analytics, - files, - genie, - jobs, - lakebase, - server, - serving, -} from "./plugins"; +export * from "./plugins/stable-exports.generated"; // Files plugin types (for custom policy authoring) export type { FileAction, @@ -88,6 +80,7 @@ export type { ServingEndpointRegistry, ServingFactory, } from "./plugins/serving/types"; +export * from "./plugins/stable-exports.generated"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts new file mode 100644 index 000000000..7fff0af71 --- /dev/null +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED from packages/appkit/src/plugins//manifest.json — do not edit. +// Run: pnpm exec tsx tools/generate-plugin-entries.ts +// +// The manifest's "stability" field is the single source of truth for which +// subpath ships each plugin. Editing this file by hand will drift it from the +// manifests and the synced appkit.plugins.json. + +export {}; diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts deleted file mode 100644 index e2dd7b5a3..000000000 --- a/packages/appkit/src/plugins/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./analytics"; -export * from "./files"; -export * from "./genie"; -export * from "./jobs"; -export * from "./lakebase"; -export * from "./server"; -export * from "./serving"; diff --git a/packages/appkit/src/plugins/stable-exports.generated.ts b/packages/appkit/src/plugins/stable-exports.generated.ts new file mode 100644 index 000000000..265a6277d --- /dev/null +++ b/packages/appkit/src/plugins/stable-exports.generated.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED from packages/appkit/src/plugins//manifest.json — do not edit. +// Run: pnpm exec tsx tools/generate-plugin-entries.ts +// +// The manifest's "stability" field is the single source of truth for which +// subpath ships each plugin. Editing this file by hand will drift it from the +// manifests and the synced appkit.plugins.json. + +export { analytics } from "./analytics"; +export { files } from "./files"; +export { genie } from "./genie"; +export { lakebase } from "./lakebase"; +export { server } from "./server"; +export { serving } from "./serving"; diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index cd51f9b63..1c19ca3e7 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -358,6 +358,29 @@ async function runPromote( } if (!options.skipSync && !options.dryRun) { + // Monorepo-only: regenerate the auto-generated stable/beta plugin + // barrels so the runtime exports match the new manifest stability. + // No-op outside the AppKit monorepo (third-party plugin projects don't + // ship the generator). + const generatorPath = path.join(cwd, "tools", "generate-plugin-entries.ts"); + if (fs.existsSync(generatorPath)) { + console.log(`\n${prefix}Regenerating plugin entry barrels...`); + const { execSync } = await import("node:child_process"); + try { + execSync("pnpm exec tsx tools/generate-plugin-entries.ts", { + cwd, + stdio: "inherit", + }); + } catch { + console.error( + `Error: post-promote 'generate-plugin-entries' failed. ` + + `Manifest was updated, but stable/beta export barrels may be stale. ` + + `Run 'pnpm run generate:plugin-entries' manually.`, + ); + process.exit(1); + } + } + console.log(`\n${prefix}Running plugin sync...`); const { execSync } = await import("node:child_process"); try { diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts new file mode 100644 index 000000000..daa952fe8 --- /dev/null +++ b/tools/generate-plugin-entries.ts @@ -0,0 +1,116 @@ +/** + * Generates per-stability barrel files that re-export built-in plugins, driven + * by each plugin's manifest.json `stability` field. Single source of truth for + * which subpath (`@databricks/appkit` vs `@databricks/appkit/beta`) ships each + * plugin, so the manifest, the synced `appkit.plugins.json`, and the runtime + * exports cannot drift apart. + * + * Inputs: packages/appkit/src/plugins//manifest.json + * Outputs: packages/appkit/src/plugins/stable-exports.generated.ts + * packages/appkit/src/plugins/beta-exports.generated.ts + * + * Run from repo root: pnpm exec tsx tools/generate-plugin-entries.ts + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { formatWithBiome } from "./format-with-biome.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, ".."); +const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); +const STABLE_OUT = path.join(PLUGINS_DIR, "stable-exports.generated.ts"); +const BETA_OUT = path.join(PLUGINS_DIR, "beta-exports.generated.ts"); + +const HEADER = `// AUTO-GENERATED from packages/appkit/src/plugins//manifest.json — do not edit. +// Run: pnpm exec tsx tools/generate-plugin-entries.ts +// +// The manifest's "stability" field is the single source of truth for which +// subpath ships each plugin. Editing this file by hand will drift it from the +// manifests and the synced appkit.plugins.json. +`; + +interface PluginInfo { + name: string; + folder: string; + stability: "beta" | "stable"; +} + +function readPluginInfos(): PluginInfo[] { + const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); + const infos: PluginInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(PLUGINS_DIR, entry.name, "manifest.json"); + if (!fs.existsSync(manifestPath)) continue; + + const raw = fs.readFileSync(manifestPath, "utf-8"); + let manifest: { + name?: string; + stability?: string; + hidden?: boolean; + }; + try { + manifest = JSON.parse(raw); + } catch (err) { + throw new Error( + `Failed to parse ${manifestPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (manifest.hidden) continue; + if (typeof manifest.name !== "string" || manifest.name.length === 0) { + throw new Error(`Manifest missing "name": ${manifestPath}`); + } + + const tier = manifest.stability ?? "stable"; + if (tier !== "stable" && tier !== "beta") { + throw new Error( + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "stable".`, + ); + } + + infos.push({ + name: manifest.name, + folder: entry.name, + stability: tier, + }); + } + + // Deterministic order so re-runs produce stable diffs. + infos.sort((a, b) => a.name.localeCompare(b.name)); + return infos; +} + +function renderBarrel(infos: PluginInfo[]): string { + if (infos.length === 0) { + return `${HEADER}\nexport {};\n`; + } + const lines = infos.map((p) => `export { ${p.name} } from "./${p.folder}";`); + return `${HEADER}\n${lines.join("\n")}\n`; +} + +function main(): void { + const infos = readPluginInfos(); + const stable = infos.filter((p) => p.stability === "stable"); + const beta = infos.filter((p) => p.stability === "beta"); + + fs.writeFileSync(STABLE_OUT, renderBarrel(stable), "utf-8"); + fs.writeFileSync(BETA_OUT, renderBarrel(beta), "utf-8"); + // Self-format so a fresh `pnpm build` doesn't leave the generated + // barrels dirty against biome's canonical formatting (matches the + // pattern set by tools/generate-schema-types.ts and + // tools/generate-registry-types.ts after PR #324). + formatWithBiome(STABLE_OUT); + formatWithBiome(BETA_OUT); + + console.log( + `Wrote ${path.relative(REPO_ROOT, STABLE_OUT)} (${stable.length} stable plugin${stable.length === 1 ? "" : "s"})`, + ); + console.log( + `Wrote ${path.relative(REPO_ROOT, BETA_OUT)} (${beta.length} beta plugin${beta.length === 1 ? "" : "s"})`, + ); +} + +main(); From df8791c80f05dfa28eb683c1d9dc5bc838464ef9 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 21:07:28 +0200 Subject: [PATCH 08/17] fix(plugin): close drift gaps surfaced by review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review-blocker fixes that together restore the PR's headline "manifest is the single source of truth, all layers stay aligned" claim: 1. Post-promote sync writes to the right file in the monorepo. Previously `runPromote` shelled out to `npx appkit plugin sync --write` with no flags, which defaults `outputPath` to `/appkit.plugins.json` and never touches `template/appkit.plugins.json` (the file shipped with the AppKit init template). In the monorepo path the manifest changed and the runtime barrels regenerated, but the synced template manifest stayed stale. Detect monorepo context the same way the generator step does (presence of `tools/generate-plugin-entries.ts`) and prefer `pnpm run sync:template` when available, which has the right flags wired (`--plugins-dir packages/appkit/src/plugins --output template/appkit.plugins.json --require-plugins server`). Outside the monorepo the original `npx appkit plugin sync --write` is still used. 2. CI freshness gate now covers `template/appkit.plugins.json`. The existing "Check generated types are up to date" step diffs the four schema/registry/barrel files but not the synced template manifest. A contributor editing a plugin's `stability` by hand and forgetting `pnpm sync:template` could land a drift between the manifest and what the Go init template branches on at scaffold time. Add a follow-up step that runs `pnpm run sync:template` and fails the build on a non-empty diff, mirroring the existing pattern. 3. Generator validates `manifest.name` and folder name against the schema regex AND a JS-identifier rule. `tools/generate-plugin-entries.ts` interpolates `name` directly into `export { ${name} } from "./${folder}";`. The schema accepts `^[a-z][a-z0-9-]*$` (kebab-case) and even shows `"my-custom-plugin"` as an example. Today this is masked because `vector-search` (the only kebab-named plugin) is `hidden: true`, but the instant any hyphenated plugin graduates the generator emits a TypeScript syntax error. The same gap is also a defense-in-depth code-injection vector (CWE-94) — a malicious manifest with `"name": "x }; await import('./evil'); //"` would generate parseable, side-effect-importing TS that runs at module load on every consumer. Add a `validateIdentifier` pass that runs both the schema regex (`^[a-z][a-z0-9-]*$`, mirroring `appkit plugin validate`) and a JS-identifier rule (`^[a-z][a-zA-Z0-9_]*$`, restricted to lowercase first letter to match the existing built-ins) on both `manifest.name` and the plugin folder name. Throws with a clear message pointing at the offending manifest and explaining how to fix it. Verified manually: - Adding a `bad-name` plugin folder with `"name": "bad-name"` makes the generator throw with the expected error message before tsdown runs. - Removing the bad plugin and re-running produces the canonical 6-stable / 0-beta barrels. - `pnpm build && pnpm -r typecheck && pnpm test && pnpm docs:build` all clean. 88 test files, 1680 tests passing. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 15 +++++++ .../cli/commands/plugin/promote/promote.ts | 16 +++++-- tools/generate-plugin-entries.ts | 45 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec421fb4b..d0b4cecf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,21 @@ jobs: echo "" exit 1 fi + - name: Check synced template manifest is up to date + run: | + pnpm run sync:template + if ! git diff --exit-code template/appkit.plugins.json; then + echo "❌ Error: template/appkit.plugins.json is out of sync with packages/appkit/src/plugins//manifest.json." + echo "" + echo "This usually happens when a plugin manifest's stability, resources, or display fields" + echo "are edited without running the sync step that regenerates the template manifest." + echo "" + echo "To fix this:" + echo " 1. Run: pnpm run sync:template" + echo " 2. Review and commit the changes" + echo "" + exit 1 + fi - name: Run Biome Check run: pnpm run check - name: Run Types Check diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 1c19ca3e7..60de5b263 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -383,15 +383,25 @@ async function runPromote( console.log(`\n${prefix}Running plugin sync...`); const { execSync } = await import("node:child_process"); + // Monorepo flavor: the AppKit monorepo's `pnpm sync:template` script + // points sync at `template/appkit.plugins.json` (the file shipped to + // consumers and read by the Go init template), not the project-root + // default. Detect the monorepo via the same generator-path probe used + // above and prefer the script when available so the manifest, the + // synced template, and the runtime barrels stay aligned. + const syncCommand = fs.existsSync(generatorPath) + ? "pnpm run sync:template" + : "npx appkit plugin sync --write"; try { - execSync("npx appkit plugin sync --write", { + execSync(syncCommand, { cwd, stdio: "inherit", }); } catch { console.error( - `Error: post-promote 'plugin sync' failed. Manifest and imports were updated, ` + - `but appkit.plugins.json may be out of sync. Run 'npx appkit plugin sync --write' manually.`, + `Error: post-promote sync ('${syncCommand}') failed. ` + + `Manifest and imports were updated, but the synced plugin manifest ` + + `may be stale. Run '${syncCommand}' manually.`, ); process.exit(1); } diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index daa952fe8..123de592b 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -36,6 +36,44 @@ interface PluginInfo { stability: "beta" | "stable"; } +/** + * Mirrors `^[a-z][a-z0-9-]*$` from `plugin-manifest.schema.json`. Catches + * malformed manifests that bypassed `appkit plugin validate`. + */ +const SCHEMA_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** + * Generator-only: the `name` field is interpolated unescaped into a TS + * `export { } from "./";` template, so it MUST be a valid + * JavaScript identifier. The schema accepts hyphens (e.g. "my-plugin"), + * which would produce `export { my-plugin }` — a TypeScript syntax error. + * + * This is also a defense-in-depth gate against code-injection (CWE-94) + * via a malicious `name` containing `}`, `;`, quotes, newlines, etc. + * + * Restricted to camelCase / underscore identifiers starting with a lowercase + * letter to match the existing built-in plugins (`analytics`, `lakebase`, + * `vectorSearch`, …) and the schema's lowercase-first rule. + */ +const JS_IDENTIFIER_PATTERN = /^[a-z][a-zA-Z0-9_]*$/; + +function validateIdentifier( + value: string, + kind: "manifest name" | "folder name", + manifestPath: string, +): void { + if (!SCHEMA_NAME_PATTERN.test(value)) { + throw new Error( + `${kind} "${value}" in ${manifestPath} doesn't match the plugin manifest schema pattern ^[a-z][a-z0-9-]*$. Run \`appkit plugin validate\` to catch this earlier.`, + ); + } + if (!JS_IDENTIFIER_PATTERN.test(value)) { + throw new Error( + `${kind} "${value}" in ${manifestPath} is not a valid JavaScript identifier (must match ^[a-z][a-zA-Z0-9_]*$). The generator interpolates this name into \`export { ${value} } from "./";\` and would emit invalid TypeScript. Rename the plugin folder + manifest \`name\` to camelCase, or set \`hidden: true\` to exclude it from the auto-generated barrels.`, + ); + } +} + function readPluginInfos(): PluginInfo[] { const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); const infos: PluginInfo[] = []; @@ -64,6 +102,13 @@ function readPluginInfos(): PluginInfo[] { throw new Error(`Manifest missing "name": ${manifestPath}`); } + // Both the manifest `name` (used as the exported binding) and the + // folder name (used as the `from` path) flow into a TS source file + // unescaped. Validate both against the schema and the JS-identifier + // rule before we emit anything. + validateIdentifier(manifest.name, "manifest name", manifestPath); + validateIdentifier(entry.name, "folder name", manifestPath); + const tier = manifest.stability ?? "stable"; if (tier !== "stable" && tier !== "beta") { throw new Error( From e049aae2cc5cc2acfb3237c31a8a08b0bf0913ae Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 21:11:27 +0200 Subject: [PATCH 09/17] chore: reconcile generated artifacts after rebase onto main Rebase onto main pulled in the new `jobs` plugin (#280) and a duplicate `export * from "./plugins/stable-exports.generated";` from the conflict resolution in `packages/appkit/src/index.ts`. Two follow-on changes: - Re-run `tools/generate-plugin-entries.ts` so `jobs` is included in `stable-exports.generated.ts`. The codegen commit on this branch predates the jobs plugin landing. - Dedupe the duplicate `export *` line in `index.ts` that crept in during the merge resolution of the codegen commit. No behavior change beyond making `jobs` reachable through `@databricks/appkit` (matching what main already does via the hand-curated barrel that this PR replaces). Signed-off-by: MarioCadenas --- packages/appkit/src/index.ts | 1 - packages/appkit/src/plugins/stable-exports.generated.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index c1ab5411e..ce8c2a405 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -54,7 +54,6 @@ export { type ToPlugin, toPlugin, } from "./plugin"; -export * from "./plugins/stable-exports.generated"; // Files plugin types (for custom policy authoring) export type { FileAction, diff --git a/packages/appkit/src/plugins/stable-exports.generated.ts b/packages/appkit/src/plugins/stable-exports.generated.ts index 265a6277d..5e48bc218 100644 --- a/packages/appkit/src/plugins/stable-exports.generated.ts +++ b/packages/appkit/src/plugins/stable-exports.generated.ts @@ -8,6 +8,7 @@ export { analytics } from "./analytics"; export { files } from "./files"; export { genie } from "./genie"; +export { jobs } from "./jobs"; export { lakebase } from "./lakebase"; export { server } from "./server"; export { serving } from "./serving"; From 054ef344ea025eea5f0944bcd966a380ad10ae00 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 21:23:34 +0200 Subject: [PATCH 10/17] fix(ci): build shared package before sync:template freshness check The new "Check synced template manifest is up to date" step in the lint job ran `pnpm run sync:template`, which goes through `packages/shared/bin/appkit.js` -> `packages/shared/dist/cli/index.js`. The lint job only does `pnpm install --frozen-lockfile` (no build), so dist doesn't exist and Node throws ERR_MODULE_NOT_FOUND. Initially tried bypassing the bin and running the CLI from source via `tsx packages/shared/src/cli/index.ts`, but that made knip discover the source file as a new entry point (since the workflow yaml now references it from outside the ignored `packages/shared` workspace) and flag `commander` and `dotenv/config` as unlisted root-level dependencies. Cleaner fix: add a step to build just the shared package (~1s) before the freshness check, then use the existing `pnpm run sync:template` script unchanged. Mirrors what users would do locally. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0b4cecf5..3f9e3d194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,12 @@ jobs: echo "" exit 1 fi + - name: Build shared package + # `pnpm run sync:template` runs through `packages/shared/bin/appkit.js` + # which imports the built `packages/shared/dist/cli/index.js`. The + # lint job doesn't otherwise build, so build just the shared package + # before invoking sync. Builds in ~2s. + run: pnpm --filter shared build:package - name: Check synced template manifest is up to date run: | pnpm run sync:template From 1e2c07c4bbae56835af4de1e0b42b7cf7e9e9732 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 28 Apr 2026 21:27:39 +0200 Subject: [PATCH 11/17] fix(ci): build shared dist directly via tsdown, not full build:package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt used `pnpm --filter shared build:package` to produce the dist needed by `pnpm sync:template`. That script also re-runs `tools/generate-schema-types.ts` which writes the raw (unformatted) version of `plugin-manifest.generated.ts` — and the next CI step ("Run Biome Check") then fails because the file no longer matches what biome would format it to. Invoke tsdown directly via `pnpm --filter shared exec tsdown` so we get the dist without regenerating any schema-derived sources. Reproduced locally: tsdown builds, sync:template succeeds, biome check stays clean. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f9e3d194..8203a4a0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,12 +68,16 @@ jobs: echo "" exit 1 fi - - name: Build shared package + - name: Build shared package dist (for sync:template CLI) # `pnpm run sync:template` runs through `packages/shared/bin/appkit.js` # which imports the built `packages/shared/dist/cli/index.js`. The - # lint job doesn't otherwise build, so build just the shared package - # before invoking sync. Builds in ~2s. - run: pnpm --filter shared build:package + # lint job doesn't otherwise build, so build just shared's dist + # before invoking sync. We invoke tsdown directly rather than + # `pnpm --filter shared build:package` because the latter also + # re-runs `generate-schema-types.ts`, which writes the raw (unformatted) + # version of `plugin-manifest.generated.ts` and trips the biome + # check that runs immediately after. ~1s. + run: pnpm --filter shared exec tsdown --config tsdown.config.ts - name: Check synced template manifest is up to date run: | pnpm run sync:template From 8c17bd733afd54ef8aae320640eb23318ca3424d Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 29 Apr 2026 10:11:36 +0200 Subject: [PATCH 12/17] feat(docs): auto-inject beta banner on plugin docs page from manifest Closes the docs side of the "manifest is the single source of truth" contract. Previously, flipping a plugin's `stability` to `"beta"` updated the runtime export barrel, the synced template manifest, and the API typedoc, but the plugin's hand-written docs page in `docs/docs/plugins/.md` looked identical to a stable plugin's. A new `tools/generate-plugin-doc-banners.ts` reads each `packages/appkit/src/plugins//manifest.json` and, when stability is non-stable, injects a Docusaurus admonition immediately after the H1: :::warning Beta plugin This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). ::: The block is delimited by marker comments and is fully idempotent: re-runs strip any existing banner first, then re-inject only when stability != "stable". Stable / absent stability removes the banner. Wiring: - New generator + a `generate:plugin-doc-banners` script in root `package.json`. Folded into `pnpm run generate:types` so the existing CI freshness gate covers it. - CI's "Check generated types are up to date" step now also diffs the eight built-in plugin doc pages (analytics, files, genie, jobs, lakebase, server, model-serving, vector-search). A drift between manifest stability and the docs page banner fails the PR. - `appkit plugin promote` (monorepo branch) now runs `pnpm run generate:types` instead of `generate-plugin-entries.ts` alone, so promote keeps both the runtime barrels AND the docs banner in sync after the manifest write. - Doc filename mapping handles the one mismatch (`serving` -> `model-serving.md`) via a small `DOC_FILE_OVERRIDES` table. - Stability docs (`docs/docs/plugins/stability.md`) updated to mention the new docs-side generator alongside the runtime barrels. Verified locally: - `pnpm run generate:types` against canonical state (all stable) writes the barrels and skips every doc page (no banner to add or remove). Working tree stays clean. - Temporarily marking serving as beta -> generator injects the banner into model-serving.md, with the manifest -> doc-file override picking the right file. Reverting -> banner stripped, file byte-identical to the committed version. - All quality gates pass: build, sync:template, check:fix, typecheck, 91 test files, docs:build. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 10 +- docs/docs/plugins/stability.md | 10 +- package.json | 1 + .../cli/commands/plugin/promote/promote.ts | 20 +-- tools/generate-plugin-doc-banners.ts | 164 ++++++++++++++++++ 5 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 tools/generate-plugin-doc-banners.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8203a4a0e..85896a648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,15 @@ jobs: packages/shared/src/schemas/plugin-manifest.generated.ts \ packages/appkit/src/registry/types.generated.ts \ packages/appkit/src/plugins/stable-exports.generated.ts \ - packages/appkit/src/plugins/beta-exports.generated.ts; then + packages/appkit/src/plugins/beta-exports.generated.ts \ + docs/docs/plugins/analytics.md \ + docs/docs/plugins/files.md \ + docs/docs/plugins/genie.md \ + docs/docs/plugins/jobs.md \ + docs/docs/plugins/lakebase.md \ + docs/docs/plugins/model-serving.md \ + docs/docs/plugins/server.md \ + docs/docs/plugins/vector-search.md; then echo "❌ Error: Generated files are out of sync with their source manifests/schemas." echo "" echo "To fix this:" diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md index 3341526e1..2c5e99716 100644 --- a/docs/docs/plugins/stability.md +++ b/docs/docs/plugins/stability.md @@ -124,12 +124,14 @@ The import path (`/beta`) only applies to first-party plugins shipped inside `@d ## For First-Party Plugin Authors (AppKit Monorepo) -Inside the AppKit monorepo, each plugin's `manifest.json` `stability` field is the **single source of truth** for which subpath ships the plugin. A build-time generator (`tools/generate-plugin-entries.ts`) reads every `packages/appkit/src/plugins//manifest.json` and writes: +Inside the AppKit monorepo, each plugin's `manifest.json` `stability` field is the **single source of truth** for which subpath ships the plugin. Two build-time generators read every `packages/appkit/src/plugins//manifest.json`: -- `packages/appkit/src/plugins/stable-exports.generated.ts` — re-exports of stable plugins, included by `src/index.ts` (the `@databricks/appkit` entry). -- `packages/appkit/src/plugins/beta-exports.generated.ts` — re-exports of beta plugins, included by `src/beta.ts` (the `@databricks/appkit/beta` entry). +- `tools/generate-plugin-entries.ts` writes the runtime export barrels: + - `packages/appkit/src/plugins/stable-exports.generated.ts` — re-exports of stable plugins, included by `src/index.ts` (the `@databricks/appkit` entry). + - `packages/appkit/src/plugins/beta-exports.generated.ts` — re-exports of beta plugins, included by `src/beta.ts` (the `@databricks/appkit/beta` entry). +- `tools/generate-plugin-doc-banners.ts` injects (or removes) a `:::warning Beta plugin` admonition at the top of each plugin's docs page (`docs/docs/plugins/.md`) so a plugin's documented stability follows its manifest. -These generated barrels are committed and verified by CI; an out-of-date barrel fails the `Check generated types are up to date` step. +All generated artifacts are committed and verified by CI; an out-of-date file fails the `Check generated types are up to date` step. The `appkit plugin promote` command detects monorepo context (presence of `tools/generate-plugin-entries.ts`) and re-runs the generator after updating the manifest, so the runtime exports, the synced `appkit.plugins.json`, and the manifest can never drift apart. diff --git a/package.json b/package.json index f9345ab65..856d2e580 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check": "biome check .", "generate:types": "tsx tools/generate-schema-types.ts && tsx tools/generate-registry-types.ts && tsx tools/generate-plugin-entries.ts && tsx tools/generate-plugin-doc-banners.ts", "generate:plugin-entries": "tsx tools/generate-plugin-entries.ts", + "generate:plugin-doc-banners": "tsx tools/generate-plugin-doc-banners.ts", "generate:app-templates": "tsx tools/generate-app-templates.ts", "check:licenses": "tsx tools/check-licenses.ts", "build:notice": "tsx tools/build-notice.ts > NOTICE.md", diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 60de5b263..2a0ee92aa 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -358,24 +358,22 @@ async function runPromote( } if (!options.skipSync && !options.dryRun) { - // Monorepo-only: regenerate the auto-generated stable/beta plugin - // barrels so the runtime exports match the new manifest stability. + // Monorepo-only: regenerate every artifact derived from plugin + // manifests (stable/beta export barrels AND the per-plugin docs-page + // stability banner) so all manifest-derived layers move together. // No-op outside the AppKit monorepo (third-party plugin projects don't - // ship the generator). + // ship the generators). const generatorPath = path.join(cwd, "tools", "generate-plugin-entries.ts"); if (fs.existsSync(generatorPath)) { - console.log(`\n${prefix}Regenerating plugin entry barrels...`); + console.log(`\n${prefix}Regenerating manifest-derived artifacts...`); const { execSync } = await import("node:child_process"); try { - execSync("pnpm exec tsx tools/generate-plugin-entries.ts", { - cwd, - stdio: "inherit", - }); + execSync("pnpm run generate:types", { cwd, stdio: "inherit" }); } catch { console.error( - `Error: post-promote 'generate-plugin-entries' failed. ` + - `Manifest was updated, but stable/beta export barrels may be stale. ` + - `Run 'pnpm run generate:plugin-entries' manually.`, + `Error: post-promote 'generate:types' failed. ` + + `Manifest was updated, but generated barrels and docs banners may be stale. ` + + `Run 'pnpm run generate:types' manually.`, ); process.exit(1); } diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts new file mode 100644 index 000000000..8347352a3 --- /dev/null +++ b/tools/generate-plugin-doc-banners.ts @@ -0,0 +1,164 @@ +/** + * Injects (or removes) a stability banner at the top of each plugin's docs + * page based on the plugin's `manifest.json` `stability` field. Closes the + * docs-side of the "manifest is the single source of truth" promise this + * PR makes for runtime exports and the synced template manifest. + * + * Inputs: packages/appkit/src/plugins//manifest.json + * Outputs: docs/docs/plugins/.md (banner block injected after the H1) + * + * Idempotent: each run strips any existing auto-generated banner via the + * marker comments and re-injects when the manifest's stability is non-stable. + * Stable / absent stability => banner removed. + * + * Run from repo root: pnpm exec tsx tools/generate-plugin-doc-banners.ts + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.join(__dirname, ".."); +const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); +const DOCS_DIR = path.join(REPO_ROOT, "docs/docs/plugins"); + +/** + * Maps a plugin's manifest `name` to the basename of its docs page when + * the page is named differently from the manifest. Default lookup is + * `.md`. Add an entry here when you create a doc page that doesn't + * follow that convention. + */ +const DOC_FILE_OVERRIDES: Record = { + serving: "model-serving.md", +}; + +const BANNER_START = ""; +const BANNER_END = ""; + +const BANNER_BODY: Record<"beta", string> = { + beta: `:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from \`@databricks/appkit/beta\`. See [Plugin Stability Tiers](./stability.md). +:::`, +}; + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Drop any existing auto-generated banner block (and its surrounding blank lines). */ +function stripBanner(content: string): string { + const re = new RegExp( + `\\n*${escapeRegex(BANNER_START)}[\\s\\S]*?${escapeRegex(BANNER_END)}\\n*`, + "g", + ); + return content.replace(re, "\n\n"); +} + +/** Insert the banner immediately after the first H1 heading. */ +function injectBanner(content: string, body: string): string { + const banner = `${BANNER_START}\n${body}\n${BANNER_END}`; + const h1 = content.match(/^# .+\n/m); + if (!h1 || h1.index === undefined) { + // No H1 found — fall back to prepending after frontmatter (if any) or to the top. + const fm = content.match(/^---[\s\S]*?\n---\n/); + const insertAt = fm ? (fm.index ?? 0) + fm[0].length : 0; + return `${content.slice(0, insertAt)}\n${banner}\n\n${content.slice(insertAt)}`; + } + const h1End = h1.index + h1[0].length; + return `${content.slice(0, h1End)}\n${banner}\n${content.slice(h1End)}`; +} + +interface PluginInfo { + name: string; + stability: "beta" | "stable"; + docFile: string; +} + +function readPluginInfos(): PluginInfo[] { + const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); + const infos: PluginInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(PLUGINS_DIR, entry.name, "manifest.json"); + if (!fs.existsSync(manifestPath)) continue; + + const raw = fs.readFileSync(manifestPath, "utf-8"); + let manifest: { name?: string; stability?: string }; + try { + manifest = JSON.parse(raw); + } catch (err) { + throw new Error( + `Failed to parse ${manifestPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (typeof manifest.name !== "string" || manifest.name.length === 0) { + continue; // not a valid plugin manifest, skip silently + } + + const tier = manifest.stability ?? "stable"; + if (tier !== "beta" && tier !== "stable") { + throw new Error( + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "stable".`, + ); + } + + const docBasename = + DOC_FILE_OVERRIDES[manifest.name] ?? `${manifest.name}.md`; + infos.push({ + name: manifest.name, + stability: tier, + docFile: path.join(DOCS_DIR, docBasename), + }); + } + + return infos.sort((a, b) => a.name.localeCompare(b.name)); +} + +function main(): void { + const infos = readPluginInfos(); + const summary: { + name: string; + action: "inject" | "strip" | "skip" | "missing"; + }[] = []; + + for (const info of infos) { + if (!fs.existsSync(info.docFile)) { + summary.push({ name: info.name, action: "missing" }); + continue; + } + + const original = fs.readFileSync(info.docFile, "utf-8"); + const stripped = stripBanner(original); + const next = + info.stability === "stable" + ? stripped + : injectBanner(stripped, BANNER_BODY[info.stability]); + + if (next === original) { + summary.push({ name: info.name, action: "skip" }); + continue; + } + fs.writeFileSync(info.docFile, next, "utf-8"); + summary.push({ + name: info.name, + action: info.stability === "stable" ? "strip" : "inject", + }); + } + + for (const s of summary) { + const rel = path.relative(REPO_ROOT, DOCS_DIR); + const docName = DOC_FILE_OVERRIDES[s.name] ?? `${s.name}.md`; + if (s.action === "missing") { + console.warn( + ` warn: ${s.name} — no doc page at ${rel}/${docName} (skipping)`, + ); + } else if (s.action === "skip") { + // No-op runs are silent to keep the build log clean. + } else { + console.log(` ${s.action}: ${rel}/${docName} (${s.name})`); + } + } +} + +main(); From 283820b6fd49ccc144312a916be00d9c50b2c2d3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 29 Apr 2026 10:43:16 +0200 Subject: [PATCH 13/17] fix(build): run plugin doc-banners generator as part of pnpm build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc-banners generator was only wired into `pnpm run generate:types`, which means a maintainer flipping a plugin's `stability` field and running `pnpm build` (the most common build flow) got the runtime barrels and the synced template manifest updated but NOT the plugin's docs page banner. Discovered by trying it: marking the jobs plugin as beta and running `pnpm build` left `docs/docs/plugins/jobs.md` unchanged — no banner appeared until `pnpm run generate:types` was invoked manually. Append the generator to the root `pnpm build` script so the docs banner follows the manifest on the same flow that handles everything else. The generator is idempotent and fast (sub-100ms for ~7 plugins), so running it on every build has negligible cost. Verified end-to-end: - Mark jobs as beta -> `pnpm build` -> jobs.md gains the `:::warning Beta plugin` admonition immediately after the H1. - Revert manifest -> `pnpm build` -> banner stripped, jobs.md byte-identical to the committed version. Signed-off-by: MarioCadenas --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 856d2e580..8f045f178 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.0.2", "packageManager": "pnpm@10.21.0", "scripts": { - "build": "pnpm -r --filter=!docs build:package && pnpm sync:template", + "build": "pnpm -r --filter=!docs build:package && pnpm sync:template && pnpm exec tsx tools/generate-plugin-doc-banners.ts", "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 .", From 56d219e758c7e713439bb9feaa7158c30bd57f09 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 29 Apr 2026 15:44:24 +0200 Subject: [PATCH 14/17] fix(plugin): scope promote import rewrite to the targeted plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #264. ## Comment: rewriteImportsInFile rewrites every beta import, not just the ## promoted one (https://github.com/databricks/appkit/pull/264#discussion_r...) Pre-fix, the rewriter did: content.split("@databricks/appkit/beta").join("@databricks/appkit") which mangled multi-specifier imports — promoting only `betaA` would move `betaB` along with it onto the stable subpath, where `betaB` doesn't exist: // before import { betaA, betaB } from "@databricks/appkit/beta"; // promote betaA --to stable, pre-fix (BROKEN) import { betaA, betaB } from "@databricks/appkit"; ^^^^^ now resolves to undefined at runtime Now: take `pluginName` as an argument, match the import statement via regex, parse the specifier list, find the targeted plugin (by binding name — handles `name`, `name as alias`, `type name`, and the kebab-to-camelCase manifest convention like `vector-search` -> `vectorSearch`), and either: - Single-specifier import: rewrite the source. - Multi-specifier import: keep remaining specifiers on the original source AND emit a new import line for the promoted specifier from the new source. Imports that don't reference the targeted plugin are left untouched. Eight new unit tests cover: single-specifier rewrite, dry-run preserved, no-op when plugin not in file, multi-specifier split (the reviewer's case), preserves `import type`, matches aliased specifiers on the binding (not the alias), multi-line specifier lists, and multiple packages in the same file rewritten independently. The existing runPromote integration test was rewritten to lock in the contract that unrelated beta imports stay put. ## Comment: redundant `as "beta"` cast in sync.ts; would lie if a ## third tier is added later The cast was unnecessary — narrowing `manifest.stability !== "stable"` on top of a truthy check already produces the non-stable variant (currently `"beta"`). Removed the cast and added a comment explaining the narrowing so a future `"alpha"` tier flows through type-correctly without revisiting this code. Signed-off-by: MarioCadenas --- .../commands/plugin/promote/promote.test.ts | 128 +++++++++++++++--- .../cli/commands/plugin/promote/promote.ts | 100 +++++++++++++- .../src/cli/commands/plugin/sync/sync.ts | 7 +- 3 files changed, 213 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts index 951d98881..34fb394ac 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -162,20 +162,21 @@ describe("rewriteImportsInFile", () => { cleanDir(tmp); }); - it("rewrites a matching import", () => { + it("rewrites a single-specifier matching import", () => { const file = path.join(tmp, "server.ts"); fs.writeFileSync(file, `import { x } from "@databricks/appkit/beta";\n`); - const result = rewriteImportsInFile(file, "/beta", "", false); + const result = rewriteImportsInFile(file, "x", "/beta", "", false); expect(result).not.toBeNull(); - expect(fs.readFileSync(file, "utf-8")).toContain(`"@databricks/appkit"`); - expect(fs.readFileSync(file, "utf-8")).not.toContain("/beta"); + const after = fs.readFileSync(file, "utf-8"); + expect(after).toContain(`from "@databricks/appkit"`); + expect(after).not.toContain("/beta"); }); it("dry-run does not write the file", () => { const file = path.join(tmp, "server.ts"); const original = `import { x } from "@databricks/appkit/beta";\n`; fs.writeFileSync(file, original); - const result = rewriteImportsInFile(file, "/beta", "", true); + const result = rewriteImportsInFile(file, "x", "/beta", "", true); expect(result).not.toBeNull(); expect(fs.readFileSync(file, "utf-8")).toBe(original); }); @@ -183,9 +184,98 @@ describe("rewriteImportsInFile", () => { it("returns null when no rewrite is needed", () => { const file = path.join(tmp, "server.ts"); fs.writeFileSync(file, `import { x } from "express";\n`); - const result = rewriteImportsInFile(file, "/beta", "", false); + const result = rewriteImportsInFile(file, "x", "/beta", "", false); expect(result).toBeNull(); }); + + it("only rewrites the targeted specifier in a multi-specifier import", () => { + // Reviewer-flagged scenario: the old split/join would have rewritten + // the entire `from "@databricks/appkit/beta"` source for the whole + // line, breaking `betaB` (which doesn't exist at the stable subpath). + const file = path.join(tmp, "server.ts"); + fs.writeFileSync( + file, + `import { betaA, betaB } from "@databricks/appkit/beta";\n`, + ); + const result = rewriteImportsInFile(file, "betaA", "/beta", "", false); + expect(result).not.toBeNull(); + const after = fs.readFileSync(file, "utf-8"); + expect(after).toContain(`{ betaB } from "@databricks/appkit/beta"`); + expect(after).toContain(`{ betaA } from "@databricks/appkit"`); + }); + + it("returns null when the specifier list does not contain the plugin", () => { + const file = path.join(tmp, "server.ts"); + const original = `import { betaA, betaB } from "@databricks/appkit/beta";\n`; + fs.writeFileSync(file, original); + // Promoting a different plugin — this file shouldn't change at all. + const result = rewriteImportsInFile(file, "ghost", "/beta", "", false); + expect(result).toBeNull(); + expect(fs.readFileSync(file, "utf-8")).toBe(original); + }); + + it("preserves the `import type` keyword across the split", () => { + const file = path.join(tmp, "types.ts"); + fs.writeFileSync( + file, + `import type { betaA, betaB } from "@databricks/appkit/beta";\n`, + ); + const result = rewriteImportsInFile(file, "betaA", "/beta", "", false); + expect(result).not.toBeNull(); + const after = fs.readFileSync(file, "utf-8"); + expect(after).toContain( + `import type { betaB } from "@databricks/appkit/beta"`, + ); + expect(after).toContain(`import type { betaA } from "@databricks/appkit"`); + }); + + it("matches an aliased specifier on the imported binding, not the alias", () => { + const file = path.join(tmp, "server.ts"); + fs.writeFileSync( + file, + `import { betaA as a, betaB } from "@databricks/appkit/beta";\n`, + ); + const result = rewriteImportsInFile(file, "betaA", "/beta", "", false); + expect(result).not.toBeNull(); + const after = fs.readFileSync(file, "utf-8"); + expect(after).toContain(`{ betaB } from "@databricks/appkit/beta"`); + expect(after).toContain(`{ betaA as a } from "@databricks/appkit"`); + }); + + it("handles multi-line specifier lists", () => { + const file = path.join(tmp, "server.ts"); + fs.writeFileSync( + file, + `import {\n betaA,\n betaB,\n} from "@databricks/appkit/beta";\n`, + ); + const result = rewriteImportsInFile(file, "betaA", "/beta", "", false); + expect(result).not.toBeNull(); + const after = fs.readFileSync(file, "utf-8"); + expect(after).toContain(`{ betaB } from "@databricks/appkit/beta"`); + expect(after).toContain(`{ betaA } from "@databricks/appkit"`); + }); + + it("rewrites both appkit and appkit-ui packages independently", () => { + const file = path.join(tmp, "App.tsx"); + fs.writeFileSync( + file, + [ + `import { betaA, betaB } from "@databricks/appkit/beta";`, + `import { Comp } from "@databricks/appkit-ui/react/beta";`, + ``, + ].join("\n"), + ); + const result = rewriteImportsInFile(file, "betaA", "/beta", "", false); + expect(result).not.toBeNull(); + const after = fs.readFileSync(file, "utf-8"); + // appkit: split — betaB stays beta, betaA goes stable. + expect(after).toContain(`{ betaB } from "@databricks/appkit/beta"`); + expect(after).toContain(`{ betaA } from "@databricks/appkit"`); + // appkit-ui: not touched (Comp isn't the promoted plugin). + expect(after).toContain( + `import { Comp } from "@databricks/appkit-ui/react/beta"`, + ); + }); }); describe("runPromote", () => { @@ -286,29 +376,33 @@ describe("runPromote", () => { expect(fs.readFileSync(manifestPath, "utf-8")).toBe(before); }); - it("rewrites imports across .ts and .tsx files in the project", async () => { + it("rewrites only the targeted plugin's imports across .ts and .tsx files", async () => { + // `my-plugin` is the kebab-case manifest name; the import binding is + // `myPlugin` (kebab-to-camelCase). The rewriter accepts both forms. writeManifest(h.cwd, "my-plugin", "beta"); + const tsFile = path.join(h.cwd, "server", "server.ts"); fs.mkdirSync(path.dirname(tsFile), { recursive: true }); fs.writeFileSync( tsFile, `import { myPlugin } from "@databricks/appkit/beta";\n`, ); + + // Unrelated beta import — promoting `my-plugin` must NOT touch this. + // Pre-fix, the blind `split/join` rewrite would have moved `Comp` + // onto the stable subpath where it doesn't exist, breaking the app. const tsxFile = path.join(h.cwd, "client", "App.tsx"); fs.mkdirSync(path.dirname(tsxFile), { recursive: true }); - fs.writeFileSync( - tsxFile, - `import { Comp } from "@databricks/appkit-ui/react/beta";\n`, - ); + const tsxOriginal = `import { Comp } from "@databricks/appkit-ui/react/beta";\n`; + fs.writeFileSync(tsxFile, tsxOriginal); await runPromote("my-plugin", { to: "stable", skipSync: true }); - expect(fs.readFileSync(tsFile, "utf-8")).toContain(`"@databricks/appkit"`); - expect(fs.readFileSync(tsFile, "utf-8")).not.toContain("/beta"); - expect(fs.readFileSync(tsxFile, "utf-8")).toContain( - `"@databricks/appkit-ui/react"`, - ); - expect(fs.readFileSync(tsxFile, "utf-8")).not.toContain("/beta"); + const tsAfter = fs.readFileSync(tsFile, "utf-8"); + expect(tsAfter).toContain(`{ myPlugin } from "@databricks/appkit"`); + expect(tsAfter).not.toContain("/beta"); + + expect(fs.readFileSync(tsxFile, "utf-8")).toBe(tsxOriginal); }); it("skips symlinked directories during the project walk", async () => { diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 2a0ee92aa..1bc1e0c1e 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -168,8 +168,59 @@ function loadManifestFromFileSync(filePath: string): unknown { return JSON.parse(raw); } +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Convert a kebab-case manifest name to its camelCase JS identifier form + * (e.g. `vector-search` -> `vectorSearch`). Mirrors the convention used by + * first-party plugin index files: a manifest's `name` field may be + * kebab-case (the schema permits `^[a-z][a-z0-9-]*$`), but the actual + * exported binding is always a JS identifier. We try both forms when + * matching specifiers in user code. + */ +function manifestNameToBinding(pluginName: string): string { + return pluginName.replace(/-+([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} + +/** + * Returns true when the named import specifier `spec` resolves to either + * `pluginName` itself or its kebab-to-camelCase JS-identifier form. + * Handles the `name`, `name as alias`, and inline-type (`type name`, + * `type name as alias`) forms. Matches on the imported binding before any + * `as` rename so a promotion finds the right specifier regardless of how + * the user aliased it. + */ +function specifierMatchesPlugin(spec: string, pluginName: string): boolean { + const stripped = spec.replace(/^type\s+/, "").trim(); + const head = stripped.split(/\s+as\s+/)[0]?.trim(); + if (!head) return false; + return head === pluginName || head === manifestNameToBinding(pluginName); +} + +/** + * Rewrite imports of `pluginName` from `` to `` + * across one file, leaving every OTHER specifier on the same import line at + * its original source. The naïve `split/join` approach this replaced was + * promoting *every* beta specifier in the file along with the targeted + * plugin — a bug because beta specifiers don't exist at the stable subpath. + * + * Behaviour: + * - If the targeted plugin is the only specifier on an import line, the + * line's source is rewritten to the new path. + * - If the import has multiple specifiers, the targeted one is moved to a + * newly-emitted import line at the new source, and the original import + * keeps the remaining specifiers at the old source. + * - Imports that don't reference `pluginName` are left untouched. + * + * Multi-line specifier lists, type-only imports (`import type { ... }`), + * inline-type specifiers (`import { type Foo }`), and `as`-aliased + * specifiers are all preserved through the rewrite. + */ function rewriteImportsInFile( filePath: string, + pluginName: string, oldSuffix: string, newSuffix: string, dryRun: boolean, @@ -187,10 +238,50 @@ function rewriteImportsInFile( 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; - } + + // Match: `import [type] { ...specifiers... } from "";` + // - `[^}]*` lets the specifier list span newlines (safe — TS imports + // don't have nested braces inside the specifier list). + // - Captures the `type ` keyword (if any), the specifier body, and + // the surrounding quote style so we can preserve them on output. + const importRe = new RegExp( + `import\\s+(type\\s+)?\\{([^}]*)\\}\\s*from\\s*(["'])${escapeRegex(oldPath)}\\3\\s*;?`, + "g", + ); + + updated = updated.replace( + importRe, + (full, typeKeyword, specifiers, quote) => { + const specList: string[] = specifiers + .split(",") + .map((s: string) => s.trim()) + .filter((s: string) => s.length > 0); + + const promotedSpec = specList.find((s) => + specifierMatchesPlugin(s, pluginName), + ); + if (!promotedSpec) { + // Plugin not in this import — leave it alone. + return full; + } + + const remaining = specList.filter( + (s) => !specifierMatchesPlugin(s, pluginName), + ); + changed = true; + + const tk = typeKeyword ?? ""; + const promotedImport = `import ${tk}{ ${promotedSpec} } from ${quote}${newPath}${quote};`; + + if (remaining.length === 0) { + // Only specifier — just rewrite the source. + return promotedImport; + } + + const remainingImport = `import ${tk}{ ${remaining.join(", ")} } from ${quote}${oldPath}${quote};`; + return `${remainingImport}\n${promotedImport}`; + }, + ); } if (!changed) return null; @@ -340,6 +431,7 @@ async function runPromote( for (const file of tsFiles) { const result = rewriteImportsInFile( file, + pluginName, oldSuffix, newSuffix, Boolean(options.dryRun), diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 9697dc2af..2a65aea1f 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -84,9 +84,14 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + // Narrowing on `!== "stable"` removes "stable"; the truthy check + // removes `undefined`. What's left is the non-stable tier set, + // which TypeScript already knows is assignable to TemplatePlugin's + // `stability` field — so no cast is needed and adding a future + // tier (e.g. "alpha") flows through type-correctly. ...(manifest.stability && manifest.stability !== "stable" && { - stability: manifest.stability as "beta", + stability: manifest.stability, }), }, ]; From 5e94a105378c2e07081c9d5a6be5bcacab8f71d2 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 15:04:15 +0200 Subject: [PATCH 15/17] refactor(plugin): rename "stable" tier to "ga" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard rename of the production-ready stability tier from "stable" to "ga" (general availability). Schema enum becomes ["beta", "ga"]; "stable" is no longer a valid manifest value. Why "ga": "stable" was overloaded — every git branch, plugin release, and SDK version is "stable" in some sense. "ga" matches the broader Databricks product vocabulary for the production-ready / semver-strict tier and reads unambiguously alongside "beta". Touched layers (every place "stable" had any meaning is renamed): - Schemas: `enum: ["beta", "stable"]` -> `["beta", "ga"]` in both `plugin-manifest.schema.json` and `template-plugins.schema.json`, with descriptions updated. Default value changes from "stable" to "ga" so absent stability still maps to the production-ready tier. - TypeScript types: `Stability = "beta" | "stable"` -> `"beta" | "ga"` across `manifest-types.ts`, `plugin-manifest.generated.ts`, `promote.ts`, `sync.ts`, `list.ts`, `create.ts`, and the two generators in `tools/`. - Promote command: TIER_ORDER, IMPORT_PATH_MAP, isStability, error messages, validation, --to flag help, and the post-promote generator/sync block. - Sync command: the strip-requiredByTemplate condition now compares against "ga" instead of "stable". - Create command: interactive prompt offers "GA" / "Beta" (label), with values "ga" / "beta". Existing default-omit logic preserved. - Generated barrel: renamed `stable-exports.generated.ts` -> `ga-exports.generated.ts` via `git mv`. `src/index.ts` and the `tools/generate-plugin-entries.ts` writer updated. CI freshness diff list and root `package.json` scripts updated too. - Doc banners generator: only beta plugins get a banner; the GA branch strips any existing banner. No banner is emitted for GA (matches the design — absence of beta = GA, no extra label needed). - Tests: bulk-renamed `to: "stable"` test args to `to: "ga"`, updated `isStability`/TIER_ORDER tests for the new tier name, and extended the legacy-rejection cases so manifests or --to flags carrying "stable" (now legacy), "experimental", or "preview" are all rejected up front. - Stability docs page: tier name updated everywhere; promotion path diagram now reads `beta ──→ ga`; current-plugins-by-tier section says "GA" instead of "Stable". Backwards compat: the schema rejects the legacy "stable" value, so a stale manifest must migrate to "ga" (or omit the field). Built-in plugins never wrote the field explicitly, so no first-party manifest needs editing. Third-party plugins that explicitly committed `"stability": "stable"` will fail validation until they update — this is the "hard rename" the user explicitly chose over a deprecation alias. CLI side (databricks/cli#5090) is intentionally NOT touched here. The CLI hardcodes a "stable" check in `Plugin.StabilityLabel()` to suppress the picker suffix for the default tier. After this rename, that check still suppresses "stable" (now legacy / never written by AppKit) but absent-stability plugins (the new GA default) already render with no suffix because the field is omitted from the JSON. So the CLI keeps working without changes; an explicit "ga" written into a manifest would render as "(ga)" in the picker, which is acceptable for now. A follow-up CLI PR can update the suppression check. Signed-off-by: MarioCadenas --- .github/workflows/ci.yml | 2 +- .../api/appkit/Interface.PluginManifest.md | 4 +- docs/docs/plugins/stability.md | 30 ++++---- .../schemas/plugin-manifest.schema.json | 6 +- .../schemas/template-plugins.schema.json | 6 +- packages/appkit/src/beta.ts | 4 +- packages/appkit/src/index.ts | 2 +- ...s.generated.ts => ga-exports.generated.ts} | 0 .../src/cli/commands/plugin/create/create.ts | 10 +-- .../commands/plugin/create/scaffold.test.ts | 2 +- .../src/cli/commands/plugin/create/types.ts | 2 +- .../shared/src/cli/commands/plugin/index.ts | 2 +- .../src/cli/commands/plugin/list/list.test.ts | 14 ++-- .../src/cli/commands/plugin/list/list.ts | 10 +-- .../src/cli/commands/plugin/manifest-types.ts | 4 +- .../commands/plugin/promote/promote.test.ts | 68 ++++++++++++------- .../cli/commands/plugin/promote/promote.ts | 22 +++--- .../src/cli/commands/plugin/sync/sync.ts | 10 +-- .../src/schemas/plugin-manifest.generated.ts | 4 +- .../src/schemas/plugin-manifest.schema.json | 6 +- .../src/schemas/template-plugins.schema.json | 6 +- tools/generate-plugin-doc-banners.ts | 16 ++--- tools/generate-plugin-entries.ts | 22 +++--- 23 files changed, 134 insertions(+), 118 deletions(-) rename packages/appkit/src/plugins/{stable-exports.generated.ts => ga-exports.generated.ts} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85896a648..8b681e267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: if ! git diff --exit-code \ packages/shared/src/schemas/plugin-manifest.generated.ts \ packages/appkit/src/registry/types.generated.ts \ - packages/appkit/src/plugins/stable-exports.generated.ts \ + packages/appkit/src/plugins/ga-exports.generated.ts \ packages/appkit/src/plugins/beta-exports.generated.ts \ docs/docs/plugins/analytics.md \ docs/docs/plugins/files.md \ diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index edee19bc5..0f12a2b48 100644 --- a/docs/docs/api/appkit/Interface.PluginManifest.md +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -216,10 +216,10 @@ Resources that must be available for the plugin to function ### stability? ```ts -optional stability: "beta" | "stable"; +optional stability: "beta" | "ga"; ``` -Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly. +Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) plugins follow semver strictly. #### Inherited from diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md index 2c5e99716..a0055fb37 100644 --- a/docs/docs/plugins/stability.md +++ b/docs/docs/plugins/stability.md @@ -10,8 +10,8 @@ AppKit plugins have a two-tier stability system that communicates API maturity a | Tier | Import Path | Contract | |------|------------|---------| -| **Beta** | `@databricks/appkit/beta` | API may change between minor releases. On a path to stable. | -| **Stable** | `@databricks/appkit` | Production ready. Follows semver strictly. | +| **Beta** | `@databricks/appkit/beta` | API may change between minor releases. On a path to GA. | +| **GA** | `@databricks/appkit` | Generally available. Production ready. Follows semver strictly. | The import path is the primary stability signal. Importing from `/beta` is explicit consent to potential breaking changes. @@ -20,7 +20,7 @@ The import path is the primary stability signal. Importing from `/beta` is expli Promotion is one-way. Plugins can enter at any tier. ``` -beta ──→ stable +beta ──→ ga ``` ## Usage @@ -28,7 +28,7 @@ beta ──→ stable ### Importing Plugins by Tier ```typescript -// Stable plugins +// GA plugins import { server, analytics } from "@databricks/appkit"; // Beta plugins @@ -60,16 +60,16 @@ The output includes a STABILITY column showing each plugin's tier. npx appkit plugin create ``` -The interactive flow prompts for a stability level (defaults to stable). +The interactive flow prompts for a stability level (defaults to GA). ### Promoting a Plugin ```bash -# Promote from beta to stable -npx appkit plugin promote my-plugin --to stable +# Promote from beta to GA +npx appkit plugin promote my-plugin --to ga # Preview changes without modifying files -npx appkit plugin promote my-plugin --to stable --dry-run +npx appkit plugin promote my-plugin --to ga --dry-run ``` The promote command: @@ -85,7 +85,7 @@ The promote command: ## Manifest Field -The `stability` field in `manifest.json` is optional. When absent, the plugin is considered stable. +The `stability` field in `manifest.json` is optional. When absent, the plugin is considered GA. ```json { @@ -97,11 +97,11 @@ The `stability` field in `manifest.json` is optional. When absent, the plugin is } ``` -Valid values: `"beta"`, `"stable"`. +Valid values: `"beta"`, `"ga"`. ## Template Manifest (appkit.plugins.json) -When `plugin sync` discovers non-stable plugins, it includes their stability in the output: +When `plugin sync` discovers non-GA plugins, it includes their stability in the output: ```json { @@ -116,7 +116,7 @@ When `plugin sync` discovers non-stable plugins, it includes their stability in } ``` -Only stable plugins can be marked `requiredByTemplate`. Non-stable plugins always remain optional during init. +Only GA plugins can be marked `requiredByTemplate`. Non-GA plugins always remain optional during init. ## For Third-Party Plugin Authors @@ -127,7 +127,7 @@ The import path (`/beta`) only applies to first-party plugins shipped inside `@d Inside the AppKit monorepo, each plugin's `manifest.json` `stability` field is the **single source of truth** for which subpath ships the plugin. Two build-time generators read every `packages/appkit/src/plugins//manifest.json`: - `tools/generate-plugin-entries.ts` writes the runtime export barrels: - - `packages/appkit/src/plugins/stable-exports.generated.ts` — re-exports of stable plugins, included by `src/index.ts` (the `@databricks/appkit` entry). + - `packages/appkit/src/plugins/ga-exports.generated.ts` — re-exports of GA plugins, included by `src/index.ts` (the `@databricks/appkit` entry). - `packages/appkit/src/plugins/beta-exports.generated.ts` — re-exports of beta plugins, included by `src/beta.ts` (the `@databricks/appkit/beta` entry). - `tools/generate-plugin-doc-banners.ts` injects (or removes) a `:::warning Beta plugin` admonition at the top of each plugin's docs page (`docs/docs/plugins/.md`) so a plugin's documented stability follows its manifest. @@ -139,14 +139,14 @@ To move a built-in plugin between tiers manually: ```bash # Edit packages/appkit/src/plugins//manifest.json -# Set "stability": "beta" (or remove the field for stable) +# Set "stability": "beta" (or remove the field for GA) pnpm run generate:plugin-entries # regenerate the barrels pnpm sync:template # regenerate appkit.plugins.json ``` ## Current Plugins by Tier -All built-in plugins are currently **stable**: +All built-in plugins are currently **GA**: - `server` -- Express HTTP server - `analytics` -- SQL query execution diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index 42467f8f3..021c7f0bf 100644 --- a/docs/static/schemas/plugin-manifest.schema.json +++ b/docs/static/schemas/plugin-manifest.schema.json @@ -98,9 +98,9 @@ }, "stability": { "type": "string", - "enum": ["beta", "stable"], - "default": "stable", - "description": "Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly." + "enum": ["beta", "ga"], + "default": "ga", + "description": "Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) plugins follow semver strictly." } }, "additionalProperties": false, diff --git a/docs/static/schemas/template-plugins.schema.json b/docs/static/schemas/template-plugins.schema.json index 5bce051da..47d385936 100644 --- a/docs/static/schemas/template-plugins.schema.json +++ b/docs/static/schemas/template-plugins.schema.json @@ -71,9 +71,9 @@ }, "stability": { "type": "string", - "enum": ["beta", "stable"], - "default": "stable", - "description": "Plugin stability level. Beta is heading to stable; APIs may change between minor releases. Stable follows semver." + "enum": ["beta", "ga"], + "default": "ga", + "description": "Plugin stability level. Beta is heading to GA; APIs may change between minor releases. GA (general availability) follows semver." }, "resources": { "type": "object", diff --git a/packages/appkit/src/beta.ts b/packages/appkit/src/beta.ts index 0e5781c8f..57db86362 100644 --- a/packages/appkit/src/beta.ts +++ b/packages/appkit/src/beta.ts @@ -1,6 +1,6 @@ // Beta 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. +// These plugins are on a path to GA and will graduate. +// Import from '@databricks/appkit' once a plugin graduates to GA. // // The exports below are auto-generated from each plugin's manifest.json // "stability" field. See tools/generate-plugin-entries.ts. diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index ce8c2a405..00fd6ff86 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -66,6 +66,7 @@ export { READ_ACTIONS, WRITE_ACTIONS, } from "./plugins/files/policy"; +export * from "./plugins/ga-exports.generated"; export type { IJobsConfig, JobAPI, @@ -79,7 +80,6 @@ export type { ServingEndpointRegistry, ServingFactory, } from "./plugins/serving/types"; -export * from "./plugins/stable-exports.generated"; // Registry types and utilities for plugin manifests export type { ConfigSchema, diff --git a/packages/appkit/src/plugins/stable-exports.generated.ts b/packages/appkit/src/plugins/ga-exports.generated.ts similarity index 100% rename from packages/appkit/src/plugins/stable-exports.generated.ts rename to packages/appkit/src/plugins/ga-exports.generated.ts diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 91e2535ca..5917cfe11 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -333,17 +333,17 @@ async function runInteractive(): Promise { process.exit(0); } - const stability = await select<"stable" | "beta">({ + const stability = await select<"ga" | "beta">({ message: "Plugin stability level", options: [ - { value: "stable", label: "Stable", hint: "API follows semver" }, + { value: "ga", label: "GA", hint: "API follows semver" }, { value: "beta", label: "Beta", - hint: "Heading to stable, API may change", + hint: "Heading to GA, API may change", }, ], - initialValue: "stable" as "stable" | "beta", + initialValue: "ga" as "ga" | "beta", }); if (isCancel(stability)) { cancel("Cancelled."); @@ -386,7 +386,7 @@ async function runInteractive(): Promise { name: (name as string).trim(), displayName: (displayName as string).trim(), description: (description as string).trim(), - stability: stability === "stable" ? undefined : stability, + stability: stability === "ga" ? 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 e5d384c22..110b28288 100644 --- a/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts +++ b/packages/shared/src/cli/commands/plugin/create/scaffold.test.ts @@ -223,7 +223,7 @@ describe("scaffold", () => { }); describe("stability field", () => { - it("omits stability from manifest when undefined (defaults to stable)", () => { + it("omits stability from manifest when undefined (defaults to ga)", () => { const tmp = makeTempDir(); tempDirs.push(tmp); const targetDir = path.join(tmp, "test"); diff --git a/packages/shared/src/cli/commands/plugin/create/types.ts b/packages/shared/src/cli/commands/plugin/create/types.ts index 518dc4e38..9ffeccb11 100644 --- a/packages/shared/src/cli/commands/plugin/create/types.ts +++ b/packages/shared/src/cli/commands/plugin/create/types.ts @@ -22,7 +22,7 @@ export interface CreateAnswers { name: string; displayName: string; description: string; - /** Only set when non-stable. Absent = "stable" (default). */ + /** Only set when non-GA. Absent = "ga" (default). */ stability?: "beta"; resources: SelectedResource[]; author?: string; diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index cc5ab6994..7b5a96acc 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -33,5 +33,5 @@ Examples: $ appkit plugin validate . $ appkit plugin list --json $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse - $ appkit plugin promote my-plugin --to stable`, + $ appkit plugin promote my-plugin --to ga`, ); 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 f89775500..e7fe8887f 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.test.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.test.ts @@ -120,7 +120,7 @@ describe("list", () => { ); }); - it("defaults stability to stable when absent", () => { + it("defaults stability to ga when absent", () => { const tmp = makeTempDir("list-stability-default"); tempDirs.push(tmp); const manifestPath = path.join(tmp, "appkit.plugins.json"); @@ -131,7 +131,7 @@ describe("list", () => { const rows = listFromManifestFile(manifestPath); for (const row of rows) { - expect(row.stability).toBe("stable"); + expect(row.stability).toBe("ga"); } }); @@ -157,10 +157,10 @@ describe("list", () => { const rows = listFromManifestFile(manifestPath); const betaRow = rows.find((r) => r.name === "beta-plugin"); - const stableRow = rows.find((r) => r.name === "server"); + const gaRow = rows.find((r) => r.name === "server"); expect(betaRow?.stability).toBe("beta"); - expect(stableRow?.stability).toBe("stable"); + expect(gaRow?.stability).toBe("ga"); }); }); @@ -235,10 +235,10 @@ describe("list", () => { expect(rows[0].stability).toBe("beta"); }); - it("defaults stability to stable in directory scan when absent", async () => { + it("defaults stability to ga in directory scan when absent", async () => { const tmp = makeTempDir("list-dir-stability-default"); tempDirs.push(tmp); - const pluginDir = path.join(tmp, "stable-plugin"); + const pluginDir = path.join(tmp, "ga-plugin"); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync( path.join(pluginDir, "manifest.json"), @@ -248,7 +248,7 @@ describe("list", () => { const rows = await listFromDirectory(tmp, path.dirname(tmp)); expect(rows).toHaveLength(1); - expect(rows[0].stability).toBe("stable"); + expect(rows[0].stability).toBe("ga"); }); it("does not load JS-only manifests by default", async () => { diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index cc1000d92..4c591d2d4 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -16,7 +16,7 @@ export interface PluginRow { name: string; displayName: string; package: string; - stability: "beta" | "stable"; + stability: "beta" | "ga"; required: number; optional: number; } @@ -37,7 +37,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: string; displayName: string; package: string; - stability?: "beta" | "stable"; + stability?: "beta" | "ga"; resources: { required: unknown[]; optional: unknown[] }; } >; @@ -54,7 +54,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: p.name, displayName: p.displayName ?? p.name, package: p.package ?? "", - stability: p.stability ?? "stable", + stability: p.stability ?? "ga", required: Array.isArray(p.resources?.required) ? p.resources.required.length : 0, @@ -107,13 +107,13 @@ async function collectPluginsRecursive( ? relPath : `./${relPath}`; const rawManifest = manifest as typeof manifest & { - stability?: "beta" | "stable"; + stability?: "beta" | "ga"; }; rows.push({ name: manifest.name, displayName: manifest.displayName ?? manifest.name, package: packagePath, - stability: rawManifest.stability ?? "stable", + stability: rawManifest.stability ?? "ga", required: Array.isArray(manifest.resources?.required) ? manifest.resources.required.length : 0, diff --git a/packages/shared/src/cli/commands/plugin/manifest-types.ts b/packages/shared/src/cli/commands/plugin/manifest-types.ts index 8b21917d5..8f649036f 100644 --- a/packages/shared/src/cli/commands/plugin/manifest-types.ts +++ b/packages/shared/src/cli/commands/plugin/manifest-types.ts @@ -17,8 +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?: "beta" | "stable"; + /** Plugin stability level. Absent or undefined means "ga" (general availability). */ + stability?: "beta" | "ga"; } 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 index 34fb394ac..6beb68b38 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.test.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -125,14 +125,18 @@ describe("validatePluginName", () => { describe("isStability", () => { it("accepts the two tiers", () => { expect(isStability("beta")).toBe(true); - expect(isStability("stable")).toBe(true); + expect(isStability("ga")).toBe(true); }); it("rejects everything else (including legacy tiers)", () => { expect(isStability("experimental")).toBe(false); expect(isStability("preview")).toBe(false); - expect(isStability("Stable")).toBe(false); - expect(isStability("STABLE")).toBe(false); + // "stable" was the previous name for the GA tier. After the rename it + // is no longer a valid stability value; manifests using it must + // migrate to "ga" (or omit the field, which defaults to GA). + expect(isStability("stable")).toBe(false); + expect(isStability("GA")).toBe(false); + expect(isStability("Ga")).toBe(false); expect(isStability("alpha")).toBe(false); expect(isStability(undefined)).toBe(false); expect(isStability(null)).toBe(false); @@ -141,12 +145,12 @@ describe("isStability", () => { }); describe("TIER_ORDER", () => { - it("orders beta < stable", () => { - expect(TIER_ORDER.beta).toBeLessThan(TIER_ORDER.stable); + it("orders beta < ga", () => { + expect(TIER_ORDER.beta).toBeLessThan(TIER_ORDER.ga); }); - it("IMPORT_PATH_MAP returns empty string for stable (root entrypoint)", () => { - expect(IMPORT_PATH_MAP.stable).toBe(""); + it("IMPORT_PATH_MAP returns empty string for ga (root entrypoint)", () => { + expect(IMPORT_PATH_MAP.ga).toBe(""); expect(IMPORT_PATH_MAP.beta).toBe("/beta"); }); }); @@ -291,7 +295,7 @@ describe("runPromote", () => { it("rejects an invalid plugin name (path traversal attempt)", async () => { await expect( - runPromote("../etc/passwd", { to: "stable", skipSync: true }), + runPromote("../etc/passwd", { to: "ga", skipSync: true }), ).rejects.toThrow(/Invalid plugin name/); }); @@ -305,7 +309,11 @@ describe("runPromote", () => { it("rejects legacy tier names as targets", async () => { writeManifest(h.cwd, "my-plugin", "beta"); - for (const legacy of ["experimental", "preview"]) { + // "experimental" and "preview" predate the beta/GA collapse; "stable" + // is the previous name for the GA tier. All three must be rejected + // up front so a stale --to flag in someone's shell history can't + // silently no-op or land a manifest with an invalid value. + for (const legacy of ["experimental", "preview", "stable"]) { h.errors.length = 0; await expect( runPromote("my-plugin", { to: legacy, skipSync: true }), @@ -316,28 +324,36 @@ describe("runPromote", () => { it("rejects when plugin is not found", async () => { await expect( - runPromote("ghost", { to: "stable", skipSync: true }), + runPromote("ghost", { to: "ga", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /not found/.test(e))).toBe(true); }); - it("rejects an invalid stability value in the manifest (cased 'Stable')", async () => { - writeManifest(h.cwd, "my-plugin", "Stable"); + it("rejects an invalid stability value in the manifest (cased 'GA')", async () => { + writeManifest(h.cwd, "my-plugin", "GA"); await expect( - runPromote("my-plugin", { to: "stable", skipSync: true }), + runPromote("my-plugin", { to: "ga", skipSync: true }), ).rejects.toThrow(/__exit:1/); expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe(true); }); it("rejects a legacy stability value in the manifest", async () => { - writeManifest(h.cwd, "my-plugin", "preview"); - await expect( - runPromote("my-plugin", { to: "stable", skipSync: true }), - ).rejects.toThrow(/__exit:1/); - expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe(true); + // "preview" predates the beta/GA collapse; "stable" predates the + // GA rename. Both must be rejected so a stale manifest can't reach + // the rest of the flow with an unknown value. + for (const legacy of ["preview", "stable"]) { + h.errors.length = 0; + writeManifest(h.cwd, `my-${legacy}-plugin`, legacy); + await expect( + runPromote(`my-${legacy}-plugin`, { to: "ga", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /invalid stability value/i.test(e))).toBe( + true, + ); + } }); - it("rejects demotion from stable (absent stability) to beta", async () => { + it("rejects demotion from GA (absent stability) to beta", async () => { writeManifest(h.cwd, "my-plugin"); await expect( runPromote("my-plugin", { to: "beta", skipSync: true }), @@ -353,10 +369,10 @@ describe("runPromote", () => { expect(h.errors.some((e) => /already at "beta"/.test(e))).toBe(true); }); - it("promotes beta to stable by removing the stability field", async () => { + it("promotes beta to ga by removing the stability field", async () => { const manifestPath = writeManifest(h.cwd, "my-plugin", "beta"); await runPromote("my-plugin", { - to: "stable", + to: "ga", skipSync: true, skipImports: true, }); @@ -368,7 +384,7 @@ describe("runPromote", () => { const manifestPath = writeManifest(h.cwd, "my-plugin", "beta"); const before = fs.readFileSync(manifestPath, "utf-8"); await runPromote("my-plugin", { - to: "stable", + to: "ga", dryRun: true, skipSync: true, skipImports: true, @@ -396,7 +412,7 @@ describe("runPromote", () => { const tsxOriginal = `import { Comp } from "@databricks/appkit-ui/react/beta";\n`; fs.writeFileSync(tsxFile, tsxOriginal); - await runPromote("my-plugin", { to: "stable", skipSync: true }); + await runPromote("my-plugin", { to: "ga", skipSync: true }); const tsAfter = fs.readFileSync(tsFile, "utf-8"); expect(tsAfter).toContain(`{ myPlugin } from "@databricks/appkit"`); @@ -421,7 +437,7 @@ describe("runPromote", () => { return; } - await runPromote("my-plugin", { to: "stable", skipSync: true }); + await runPromote("my-plugin", { to: "ga", skipSync: true }); expect(fs.readFileSync(outsideTs, "utf-8")).toContain( "@databricks/appkit/beta", @@ -440,7 +456,7 @@ describe("runPromote", () => { ); await expect( runPromote("installed-plugin", { - to: "stable", + to: "ga", skipSync: true, skipImports: true, }), @@ -463,7 +479,7 @@ describe("runPromote", () => { JSON.stringify({ name: "ghost", stability: "beta" }), ); await expect( - runPromote("../../../elsewhere", { to: "stable", skipSync: true }), + runPromote("../../../elsewhere", { to: "ga", skipSync: true }), ).rejects.toThrow(/Invalid plugin name/); }); }); diff --git a/packages/shared/src/cli/commands/plugin/promote/promote.ts b/packages/shared/src/cli/commands/plugin/promote/promote.ts index 1bc1e0c1e..39d62d602 100644 --- a/packages/shared/src/cli/commands/plugin/promote/promote.ts +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -6,16 +6,16 @@ import { resolveManifestInDir } from "../manifest-resolve"; import { isWithinDirectory } from "../sync/sync"; import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; -type Stability = "beta" | "stable"; +type Stability = "beta" | "ga"; const TIER_ORDER: Record = { beta: 0, - stable: 1, + ga: 1, }; const IMPORT_PATH_MAP: Record = { beta: "/beta", - stable: "", + ga: "", }; /** Aligned with sync.ts and list.ts; keep all plugin-tree walks at the same cap. */ @@ -76,7 +76,7 @@ function validatePluginName(pluginName: string): void { } function isStability(value: unknown): value is Stability { - return value === "beta" || value === "stable"; + return value === "beta" || value === "ga"; } function findPluginManifest( @@ -204,7 +204,7 @@ function specifierMatchesPlugin(spec: string, pluginName: string): boolean { * across one file, leaving every OTHER specifier on the same import line at * its original source. The naïve `split/join` approach this replaced was * promoting *every* beta specifier in the file along with the targeted - * plugin — a bug because beta specifiers don't exist at the stable subpath. + * plugin — a bug because beta specifiers don't exist at the GA subpath. * * Behaviour: * - If the targeted plugin is the only specifier on an import line, the @@ -341,7 +341,7 @@ async function runPromote( if (!isStability(options.to)) { console.error( - `Invalid target tier "${options.to}". Must be one of: beta, stable.`, + `Invalid target tier "${options.to}". Must be one of: beta, ga.`, ); process.exit(1); } @@ -383,11 +383,11 @@ async function runPromote( process.exit(1); } - const rawStability = raw.stability ?? "stable"; + const rawStability = raw.stability ?? "ga"; if (!isStability(rawStability)) { console.error( `Manifest at ${path.relative(cwd, manifestPath)} has an invalid stability value "${String(rawStability)}". ` + - `Must be one of: beta, stable (or omitted for stable).`, + `Must be one of: beta, ga (or omitted for ga).`, ); process.exit(1); } @@ -409,7 +409,7 @@ async function runPromote( const prefix = options.dryRun ? "[dry-run] " : ""; - if (target === "stable") { + if (target === "ga") { delete raw.stability; } else { raw.stability = target; @@ -451,7 +451,7 @@ async function runPromote( if (!options.skipSync && !options.dryRun) { // Monorepo-only: regenerate every artifact derived from plugin - // manifests (stable/beta export barrels AND the per-plugin docs-page + // manifests (ga/beta export barrels AND the per-plugin docs-page // stability banner) so all manifest-derived layers move together. // No-op outside the AppKit monorepo (third-party plugin projects don't // ship the generators). @@ -520,7 +520,7 @@ export { 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 (beta, stable)") + .requiredOption("--to ", "Target stability tier (beta, ga)") .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") diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 2a65aea1f..5143fb474 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -84,13 +84,13 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), - // Narrowing on `!== "stable"` removes "stable"; the truthy check - // removes `undefined`. What's left is the non-stable tier set, + // Narrowing on `!== "ga"` removes "ga"; the truthy check + // removes `undefined`. What's left is the non-GA tier set, // which TypeScript already knows is assignable to TemplatePlugin's // `stability` field — so no cast is needed and adding a future // tier (e.g. "alpha") flows through type-correctly. ...(manifest.stability && - manifest.stability !== "stable" && { + manifest.stability !== "ga" && { stability: manifest.stability, }), }, @@ -761,12 +761,12 @@ async function runPluginsSync(options: { } } - // Step 6b: Strip requiredByTemplate for non-stable plugins + // Step 6b: Strip requiredByTemplate for non-GA plugins for (const plugin of Object.values(plugins)) { if ( plugin.requiredByTemplate && plugin.stability && - plugin.stability !== "stable" + plugin.stability !== "ga" ) { plugin.requiredByTemplate = undefined; } diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts index 9167dcf21..dd30f27d3 100644 --- a/packages/shared/src/schemas/plugin-manifest.generated.ts +++ b/packages/shared/src/schemas/plugin-manifest.generated.ts @@ -215,9 +215,9 @@ export interface PluginManifest { */ hidden?: boolean; /** - * Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly. + * Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) plugins follow semver strictly. */ - stability?: "beta" | "stable"; + stability?: "beta" | "ga"; } /** * 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 42467f8f3..021c7f0bf 100644 --- a/packages/shared/src/schemas/plugin-manifest.schema.json +++ b/packages/shared/src/schemas/plugin-manifest.schema.json @@ -98,9 +98,9 @@ }, "stability": { "type": "string", - "enum": ["beta", "stable"], - "default": "stable", - "description": "Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to stable. Stable plugins follow semver strictly." + "enum": ["beta", "ga"], + "default": "ga", + "description": "Plugin stability level. Beta plugins may have breaking API changes between minor releases but are on a path to GA. GA (general availability) 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 5bce051da..47d385936 100644 --- a/packages/shared/src/schemas/template-plugins.schema.json +++ b/packages/shared/src/schemas/template-plugins.schema.json @@ -71,9 +71,9 @@ }, "stability": { "type": "string", - "enum": ["beta", "stable"], - "default": "stable", - "description": "Plugin stability level. Beta is heading to stable; APIs may change between minor releases. Stable follows semver." + "enum": ["beta", "ga"], + "default": "ga", + "description": "Plugin stability level. Beta is heading to GA; APIs may change between minor releases. GA (general availability) follows semver." }, "resources": { "type": "object", diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts index 8347352a3..1f6b4b48f 100644 --- a/tools/generate-plugin-doc-banners.ts +++ b/tools/generate-plugin-doc-banners.ts @@ -8,8 +8,8 @@ * Outputs: docs/docs/plugins/.md (banner block injected after the H1) * * Idempotent: each run strips any existing auto-generated banner via the - * marker comments and re-injects when the manifest's stability is non-stable. - * Stable / absent stability => banner removed. + * marker comments and re-injects when the manifest's stability is non-GA. + * GA / absent stability => banner removed. * * Run from repo root: pnpm exec tsx tools/generate-plugin-doc-banners.ts */ @@ -70,7 +70,7 @@ function injectBanner(content: string, body: string): string { interface PluginInfo { name: string; - stability: "beta" | "stable"; + stability: "beta" | "ga"; docFile: string; } @@ -96,10 +96,10 @@ function readPluginInfos(): PluginInfo[] { continue; // not a valid plugin manifest, skip silently } - const tier = manifest.stability ?? "stable"; - if (tier !== "beta" && tier !== "stable") { + const tier = manifest.stability ?? "ga"; + if (tier !== "beta" && tier !== "ga") { throw new Error( - `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "stable".`, + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "ga".`, ); } @@ -131,7 +131,7 @@ function main(): void { const original = fs.readFileSync(info.docFile, "utf-8"); const stripped = stripBanner(original); const next = - info.stability === "stable" + info.stability === "ga" ? stripped : injectBanner(stripped, BANNER_BODY[info.stability]); @@ -142,7 +142,7 @@ function main(): void { fs.writeFileSync(info.docFile, next, "utf-8"); summary.push({ name: info.name, - action: info.stability === "stable" ? "strip" : "inject", + action: info.stability === "ga" ? "strip" : "inject", }); } diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts index 123de592b..f7517defa 100644 --- a/tools/generate-plugin-entries.ts +++ b/tools/generate-plugin-entries.ts @@ -6,7 +6,7 @@ * exports cannot drift apart. * * Inputs: packages/appkit/src/plugins//manifest.json - * Outputs: packages/appkit/src/plugins/stable-exports.generated.ts + * Outputs: packages/appkit/src/plugins/ga-exports.generated.ts * packages/appkit/src/plugins/beta-exports.generated.ts * * Run from repo root: pnpm exec tsx tools/generate-plugin-entries.ts @@ -19,7 +19,7 @@ import { formatWithBiome } from "./format-with-biome.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.join(__dirname, ".."); const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); -const STABLE_OUT = path.join(PLUGINS_DIR, "stable-exports.generated.ts"); +const GA_OUT = path.join(PLUGINS_DIR, "ga-exports.generated.ts"); const BETA_OUT = path.join(PLUGINS_DIR, "beta-exports.generated.ts"); const HEADER = `// AUTO-GENERATED from packages/appkit/src/plugins//manifest.json — do not edit. @@ -33,7 +33,7 @@ const HEADER = `// AUTO-GENERATED from packages/appkit/src/plugins//manife interface PluginInfo { name: string; folder: string; - stability: "beta" | "stable"; + stability: "beta" | "ga"; } /** @@ -109,10 +109,10 @@ function readPluginInfos(): PluginInfo[] { validateIdentifier(manifest.name, "manifest name", manifestPath); validateIdentifier(entry.name, "folder name", manifestPath); - const tier = manifest.stability ?? "stable"; - if (tier !== "stable" && tier !== "beta") { + const tier = manifest.stability ?? "ga"; + if (tier !== "ga" && tier !== "beta") { throw new Error( - `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "stable".`, + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "ga".`, ); } @@ -123,7 +123,7 @@ function readPluginInfos(): PluginInfo[] { }); } - // Deterministic order so re-runs produce stable diffs. + // Deterministic order so re-runs produce reproducible diffs. infos.sort((a, b) => a.name.localeCompare(b.name)); return infos; } @@ -138,20 +138,20 @@ function renderBarrel(infos: PluginInfo[]): string { function main(): void { const infos = readPluginInfos(); - const stable = infos.filter((p) => p.stability === "stable"); + const ga = infos.filter((p) => p.stability === "ga"); const beta = infos.filter((p) => p.stability === "beta"); - fs.writeFileSync(STABLE_OUT, renderBarrel(stable), "utf-8"); + fs.writeFileSync(GA_OUT, renderBarrel(ga), "utf-8"); fs.writeFileSync(BETA_OUT, renderBarrel(beta), "utf-8"); // Self-format so a fresh `pnpm build` doesn't leave the generated // barrels dirty against biome's canonical formatting (matches the // pattern set by tools/generate-schema-types.ts and // tools/generate-registry-types.ts after PR #324). - formatWithBiome(STABLE_OUT); + formatWithBiome(GA_OUT); formatWithBiome(BETA_OUT); console.log( - `Wrote ${path.relative(REPO_ROOT, STABLE_OUT)} (${stable.length} stable plugin${stable.length === 1 ? "" : "s"})`, + `Wrote ${path.relative(REPO_ROOT, GA_OUT)} (${ga.length} GA plugin${ga.length === 1 ? "" : "s"})`, ); console.log( `Wrote ${path.relative(REPO_ROOT, BETA_OUT)} (${beta.length} beta plugin${beta.length === 1 ? "" : "s"})`, From 2713a7036b65183fcbd3ebf4c4a8bb0b690b8ebb Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 15:43:56 +0200 Subject: [PATCH 16/17] fix(plugin): propagate stability from node_modules sync; harden doc-banner paths Signed-off-by: MarioCadenas --- .../src/cli/commands/plugin/sync/sync.ts | 4 ++ tools/generate-plugin-doc-banners.ts | 45 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 5143fb474..10258c81a 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -422,6 +422,10 @@ async function scanForPlugins( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.stability && + manifest.stability !== "ga" && { + stability: manifest.stability, + }), } satisfies TemplatePlugin; } } diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts index 1f6b4b48f..470729f6c 100644 --- a/tools/generate-plugin-doc-banners.ts +++ b/tools/generate-plugin-doc-banners.ts @@ -22,6 +22,24 @@ const REPO_ROOT = path.join(__dirname, ".."); const PLUGINS_DIR = path.join(REPO_ROOT, "packages/appkit/src/plugins"); const DOCS_DIR = path.join(REPO_ROOT, "docs/docs/plugins"); +/** + * Same as `plugin-manifest.schema.json` `name` pattern; keeps `path.join` targets + * under `docs/docs/plugins` (defense in depth vs path traversal in `name`). + */ +const SCHEMA_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** + * Checks whether a resolved file path is within a given directory boundary. + */ +function isWithinDirectory(filePath: string, boundary: string): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedBoundary = path.resolve(boundary); + return ( + resolvedPath === resolvedBoundary || + resolvedPath.startsWith(`${resolvedBoundary}${path.sep}`) + ); +} + /** * Maps a plugin's manifest `name` to the basename of its docs page when * the page is named differently from the manifest. Default lookup is @@ -96,6 +114,12 @@ function readPluginInfos(): PluginInfo[] { continue; // not a valid plugin manifest, skip silently } + if (!SCHEMA_NAME_PATTERN.test(manifest.name)) { + throw new Error( + `Manifest name "${manifest.name}" in ${manifestPath} doesn't match the plugin manifest schema pattern ^[a-z][a-z0-9-]*$.`, + ); + } + const tier = manifest.stability ?? "ga"; if (tier !== "beta" && tier !== "ga") { throw new Error( @@ -105,10 +129,29 @@ function readPluginInfos(): PluginInfo[] { const docBasename = DOC_FILE_OVERRIDES[manifest.name] ?? `${manifest.name}.md`; + if ( + docBasename.includes("..") || + docBasename !== path.basename(docBasename) || + docBasename.includes(path.sep) + ) { + throw new Error( + `Invalid docs basename "${docBasename}" derived for manifest at ${manifestPath}.`, + ); + } + + const docFile = path.join(DOCS_DIR, docBasename); + const resolvedDoc = path.resolve(docFile); + const resolvedDocsDir = path.resolve(DOCS_DIR); + if (!isWithinDirectory(resolvedDoc, resolvedDocsDir)) { + throw new Error( + `Resolved docs path escapes ${resolvedDocsDir}: ${resolvedDoc}`, + ); + } + infos.push({ name: manifest.name, stability: tier, - docFile: path.join(DOCS_DIR, docBasename), + docFile, }); } From c1604624767334196b865877a9d3c29a2a60f802 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 15:44:59 +0200 Subject: [PATCH 17/17] docs(plugins): document sync stability paths and doc-banner name checks Signed-off-by: MarioCadenas --- docs/docs/plugins/stability.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md index a0055fb37..1a1ba86e4 100644 --- a/docs/docs/plugins/stability.md +++ b/docs/docs/plugins/stability.md @@ -101,7 +101,7 @@ Valid values: `"beta"`, `"ga"`. ## Template Manifest (appkit.plugins.json) -When `plugin sync` discovers non-GA plugins, it includes their stability in the output: +When `plugin sync` discovers non-GA plugins, it includes their stability in the output. That is true for **every** discovery path: plugins resolved from your server file, from `--plugins-dir` / local plugin trees, and from known packages under `node_modules` (for example `@databricks/appkit`). The tier in each plugin’s `manifest.json` is always reflected in the synced template manifest when it is not GA. ```json { @@ -129,7 +129,7 @@ Inside the AppKit monorepo, each plugin's `manifest.json` `stability` field is t - `tools/generate-plugin-entries.ts` writes the runtime export barrels: - `packages/appkit/src/plugins/ga-exports.generated.ts` — re-exports of GA plugins, included by `src/index.ts` (the `@databricks/appkit` entry). - `packages/appkit/src/plugins/beta-exports.generated.ts` — re-exports of beta plugins, included by `src/beta.ts` (the `@databricks/appkit/beta` entry). -- `tools/generate-plugin-doc-banners.ts` injects (or removes) a `:::warning Beta plugin` admonition at the top of each plugin's docs page (`docs/docs/plugins/.md`) so a plugin's documented stability follows its manifest. +- `tools/generate-plugin-doc-banners.ts` injects (or removes) a `:::warning Beta plugin` admonition at the top of each plugin's docs page (`docs/docs/plugins/.md`) so a plugin's documented stability follows its manifest. The script only writes under `docs/docs/plugins/`: each manifest `name` must match the plugin schema pattern (`^[a-z][a-z0-9-]*$`), and resolved doc paths are checked so a malformed `name` cannot escape that directory. All generated artifacts are committed and verified by CI; an out-of-date file fails the `Check generated types are up to date` step. @@ -140,16 +140,6 @@ To move a built-in plugin between tiers manually: ```bash # Edit packages/appkit/src/plugins//manifest.json # Set "stability": "beta" (or remove the field for GA) -pnpm run generate:plugin-entries # regenerate the barrels -pnpm sync:template # regenerate appkit.plugins.json +pnpm run generate:types # regenerates schema/registry types, export barrels, and doc banners +pnpm sync:template # regenerates template/appkit.plugins.json ``` - -## Current Plugins by Tier - -All built-in plugins are currently **GA**: - -- `server` -- Express HTTP server -- `analytics` -- SQL query execution -- `files` -- Multi-volume file browser -- `genie` -- Genie Space integration -- `lakebase` -- Postgres Autoscaling