diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cd0af7dc..8b681e267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,8 +55,20 @@ 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/ga-exports.generated.ts \ + 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:" echo " 1. Run: pnpm run generate:types" @@ -64,6 +76,31 @@ jobs: echo "" exit 1 fi + - 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 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 + 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/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md index 84ff24870..0f12a2b48 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: "beta" | "ga"; +``` + +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 + +```ts +Omit.stability +``` + +*** + ### version? ```ts diff --git a/docs/docs/plugins/stability.md b/docs/docs/plugins/stability.md new file mode 100644 index 000000000..1a1ba86e4 --- /dev/null +++ b/docs/docs/plugins/stability.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 2 +--- + +# Plugin Stability Tiers + +AppKit plugins have a two-tier stability system that communicates API maturity and breaking-change expectations. + +## Tiers + +| Tier | Import Path | Contract | +|------|------------|---------| +| **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. + +## Promotion Path + +Promotion is one-way. Plugins can enter at any tier. + +``` +beta ──→ ga +``` + +## Usage + +### Importing Plugins by Tier + +```typescript +// GA plugins +import { server, analytics } from "@databricks/appkit"; + +// Beta plugins +import { someBetaPlugin } from "@databricks/appkit/beta"; +``` + +### UI Components + +`@databricks/appkit-ui` mirrors the same pattern: + +```typescript +import { SomeComponent } from "@databricks/appkit-ui/react/beta"; +import { someUtil } from "@databricks/appkit-ui/js/beta"; +``` + +## 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 GA). + +### Promoting a Plugin + +```bash +# 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 ga --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 +- `--allow-installed` -- Allow promoting a plugin that lives only under `node_modules` (advanced) + +## Manifest Field + +The `stability` field in `manifest.json` is optional. When absent, the plugin is considered GA. + +```json +{ + "name": "my-plugin", + "displayName": "My Plugin", + "description": "An in-development feature", + "stability": "beta", + "resources": { "required": [], "optional": [] } +} +``` + +Valid values: `"beta"`, `"ga"`. + +## Template Manifest (appkit.plugins.json) + +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 +{ + "version": "1.1", + "plugins": { + "my-plugin": { + "name": "my-plugin", + "stability": "beta", + "package": "@databricks/appkit" + } + } +} +``` + +Only GA plugins can be marked `requiredByTemplate`. Non-GA plugins always remain optional during init. + +## For Third-Party Plugin Authors + +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. 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/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. 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. + +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 GA) +pnpm run generate:types # regenerates schema/registry types, export barrels, and doc banners +pnpm sync:template # regenerates template/appkit.plugins.json +``` diff --git a/docs/static/schemas/plugin-manifest.schema.json b/docs/static/schemas/plugin-manifest.schema.json index ed4ef5731..021c7f0bf 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": ["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 290edd059..47d385936 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": ["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", "required": ["required", "optional"], diff --git a/package.json b/package.json index df8bec590..8f045f178 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "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 .", "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: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/appkit-ui/package.json b/packages/appkit-ui/package.json index 3cc2b3bba..c8ed07803 100644 --- a/packages/appkit-ui/package.json +++ b/packages/appkit-ui/package.json @@ -27,10 +27,18 @@ "development": "./src/js/index.ts", "default": "./dist/js/index.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/beta": { + "development": "./src/react/beta.ts", + "default": "./dist/react/beta.js" + }, "./package.json": "./package.json", "./styles.css": { "development": "./src/react/styles/globals.css", @@ -111,7 +119,9 @@ "publishConfig": { "exports": { "./js": "./dist/js/index.js", + "./js/beta": "./dist/js/beta.js", "./react": "./dist/react/index.js", + "./react/beta": "./dist/react/beta.js", "./package.json": "./package.json", "./styles.css": "./dist/styles.css" } diff --git a/packages/appkit-ui/src/js/beta.ts b/packages/appkit-ui/src/js/beta.ts new file mode 100644 index 000000000..088ff80b1 --- /dev/null +++ b/packages/appkit-ui/src/js/beta.ts @@ -0,0 +1,2 @@ +// 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/react/beta.ts b/packages/appkit-ui/src/react/beta.ts new file mode 100644 index 000000000..0405e50de --- /dev/null +++ b/packages/appkit-ui/src/react/beta.ts @@ -0,0 +1,2 @@ +// 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/tsdown.config.ts b/packages/appkit-ui/tsdown.config.ts index f7cb4d4ac..f55a51457 100644 --- a/packages/appkit-ui/tsdown.config.ts +++ b/packages/appkit-ui/tsdown.config.ts @@ -4,7 +4,12 @@ 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/beta.ts", + "src/react/index.ts", + "src/react/beta.ts", + ], outDir: "dist", platform: "browser", minify: false, diff --git a/packages/appkit/package.json b/packages/appkit/package.json index dc80f936f..bddc99a8b 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -29,6 +29,10 @@ "development": "./src/index.ts", "default": "./dist/index.js" }, + "./beta": { + "development": "./src/beta.ts", + "default": "./dist/beta.js" + }, "./type-generator": { "types": "./dist/type-generator/index.d.ts", "development": "./src/type-generator/index.ts", @@ -41,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", @@ -95,6 +99,7 @@ "publishConfig": { "exports": { ".": "./dist/index.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/beta.ts b/packages/appkit/src/beta.ts new file mode 100644 index 000000000..57db86362 --- /dev/null +++ b/packages/appkit/src/beta.ts @@ -0,0 +1,7 @@ +// Beta plugins -- APIs may change between minor releases. +// 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. +export * from "./plugins/beta-exports.generated"; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index eecda8e3d..00fd6ff86 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -54,15 +54,6 @@ export { type ToPlugin, toPlugin, } from "./plugin"; -export { - analytics, - files, - genie, - jobs, - lakebase, - server, - serving, -} from "./plugins"; // Files plugin types (for custom policy authoring) export type { FileAction, @@ -75,6 +66,7 @@ export { READ_ACTIONS, WRITE_ACTIONS, } from "./plugins/files/policy"; +export * from "./plugins/ga-exports.generated"; export type { IJobsConfig, JobAPI, 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/ga-exports.generated.ts b/packages/appkit/src/plugins/ga-exports.generated.ts new file mode 100644 index 000000000..5e48bc218 --- /dev/null +++ b/packages/appkit/src/plugins/ga-exports.generated.ts @@ -0,0 +1,14 @@ +// 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 { jobs } from "./jobs"; +export { lakebase } from "./lakebase"; +export { server } from "./server"; +export { serving } from "./serving"; 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/tsdown.config.ts b/packages/appkit/tsdown.config.ts index 976987142..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", + 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 2d516bedf..5917cfe11 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -333,6 +333,23 @@ async function runInteractive(): Promise { process.exit(0); } + const stability = await select<"ga" | "beta">({ + message: "Plugin stability level", + options: [ + { value: "ga", label: "GA", hint: "API follows semver" }, + { + value: "beta", + label: "Beta", + hint: "Heading to GA, API may change", + }, + ], + initialValue: "ga" as "ga" | "beta", + }); + 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 +386,7 @@ async function runInteractive(): Promise { name: (name as string).trim(), displayName: (displayName as string).trim(), description: (description as string).trim(), + 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 7283cb894..110b28288 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,38 @@ describe("scaffold", () => { }); }); + describe("stability field", () => { + it("omits stability from manifest when undefined (defaults to ga)", () => { + 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: "beta" when set', () => { + const tmp = makeTempDir(); + tempDirs.push(tmp); + const targetDir = path.join(tmp, "test"); + + scaffoldPlugin( + targetDir, + { ...BASE_ANSWERS, stability: "beta" }, + { isolated: false }, + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(targetDir, "manifest.json"), "utf-8"), + ); + expect(manifest.stability).toBe("beta"); + }); + }); + 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..9ffeccb11 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-GA. Absent = "ga" (default). */ + 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 d2ff00a96..7b5a96acc 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 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 2315362c6..e7fe8887f 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,49 @@ describe("list", () => { /Failed to parse manifest file/, ); }); + + it("defaults stability to ga 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("ga"); + } + }); + + 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, + beta: { + name: "beta-plugin", + displayName: "Beta Plugin", + package: "@databricks/appkit", + stability: "beta", + resources: { required: [], optional: [] }, + }, + }, + }; + const manifestPath = path.join(tmp, "appkit.plugins.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const rows = listFromManifestFile(manifestPath); + const betaRow = rows.find((r) => r.name === "beta-plugin"); + const gaRow = rows.find((r) => r.name === "server"); + + expect(betaRow?.stability).toBe("beta"); + expect(gaRow?.stability).toBe("ga"); + }); }); describe("listFromDirectory", () => { @@ -172,6 +215,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, "beta-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "manifest.json"), + JSON.stringify({ + ...PLUGIN_MANIFEST_JSON, + name: "beta-feature", + stability: "beta", + }), + ); + + const rows = await listFromDirectory(tmp, path.dirname(tmp)); + + expect(rows).toHaveLength(1); + expect(rows[0].stability).toBe("beta"); + }); + + 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, "ga-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("ga"); + }); + 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..4c591d2d4 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: "beta" | "ga"; required: number; optional: number; } @@ -36,6 +37,7 @@ export function listFromManifestFile(manifestPath: string): PluginRow[] { name: string; displayName: string; package: string; + stability?: "beta" | "ga"; 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 ?? "ga", 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?: "beta" | "ga"; + }; rows.push({ name: manifest.name, displayName: manifest.displayName ?? manifest.name, package: packagePath, + stability: rawManifest.stability ?? "ga", 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..8f649036f 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 "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 new file mode 100644 index 000000000..6beb68b38 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/promote/promote.test.ts @@ -0,0 +1,485 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +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-")); +} + +function cleanDir(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } +} + +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: + "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 !== undefined) manifest.stability = stability; + const manifestPath = path.join(pluginDir, "manifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + return manifestPath; +} + +interface PromoteHarness { + cwd: string; + cleanup: () => void; + errors: string[]; + logs: string[]; +} + +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); + }, + }; +} + +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(); + }); + + 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(); + }); + + 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); + }); +}); + +describe("isStability", () => { + it("accepts the two tiers", () => { + expect(isStability("beta")).toBe(true); + expect(isStability("ga")).toBe(true); + }); + + it("rejects everything else (including legacy tiers)", () => { + expect(isStability("experimental")).toBe(false); + expect(isStability("preview")).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); + expect(isStability(1)).toBe(false); + }); +}); + +describe("TIER_ORDER", () => { + it("orders beta < ga", () => { + expect(TIER_ORDER.beta).toBeLessThan(TIER_ORDER.ga); + }); + + it("IMPORT_PATH_MAP returns empty string for ga (root entrypoint)", () => { + expect(IMPORT_PATH_MAP.ga).toBe(""); + expect(IMPORT_PATH_MAP.beta).toBe("/beta"); + }); +}); + +describe("rewriteImportsInFile", () => { + let tmp: string; + + beforeEach(() => { + tmp = makeTempDir(); + }); + + afterEach(() => { + cleanDir(tmp); + }); + + 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, "x", "/beta", "", false); + expect(result).not.toBeNull(); + 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, "x", "/beta", "", true); + expect(result).not.toBeNull(); + expect(fs.readFileSync(file, "utf-8")).toBe(original); + }); + + 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, "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", () => { + let h: PromoteHarness; + + beforeEach(() => { + h = setupHarness(); + }); + + afterEach(() => { + h.cleanup(); + }); + + it("rejects an invalid plugin name (path traversal attempt)", async () => { + await expect( + runPromote("../etc/passwd", { to: "ga", skipSync: true }), + ).rejects.toThrow(/Invalid plugin name/); + }); + + it("rejects an invalid target tier", async () => { + 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"); + // "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 }), + ).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: "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 'GA')", async () => { + writeManifest(h.cwd, "my-plugin", "GA"); + await expect( + 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 () => { + // "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 GA (absent stability) to beta", async () => { + writeManifest(h.cwd, "my-plugin"); + await expect( + 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", "beta"); + await expect( + runPromote("my-plugin", { to: "beta", skipSync: true }), + ).rejects.toThrow(/__exit:1/); + expect(h.errors.some((e) => /already at "beta"/.test(e))).toBe(true); + }); + + it("promotes beta to ga by removing the stability field", async () => { + const manifestPath = writeManifest(h.cwd, "my-plugin", "beta"); + await runPromote("my-plugin", { + to: "ga", + 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", "beta"); + const before = fs.readFileSync(manifestPath, "utf-8"); + await runPromote("my-plugin", { + to: "ga", + dryRun: true, + skipSync: true, + skipImports: true, + }); + expect(fs.readFileSync(manifestPath, "utf-8")).toBe(before); + }); + + 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 }); + const tsxOriginal = `import { Comp } from "@databricks/appkit-ui/react/beta";\n`; + fs.writeFileSync(tsxFile, tsxOriginal); + + await runPromote("my-plugin", { to: "ga", skipSync: true }); + + 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 () => { + 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/beta";\n`, + ); + const link = path.join(h.cwd, "linked"); + try { + fs.symlinkSync(realOutside, link, "dir"); + } catch { + return; + } + + await runPromote("my-plugin", { to: "ga", skipSync: true }); + + expect(fs.readFileSync(outsideTs, "utf-8")).toContain( + "@databricks/appkit/beta", + ); + } finally { + cleanDir(realOutside); + } + }); + + it("refuses to mutate a node_modules-only manifest without --allow-installed", async () => { + const manifestPath = writeManifest( + h.cwd, + "installed-plugin", + "beta", + "node_modules/@databricks/appkit/dist/plugins", + ); + await expect( + runPromote("installed-plugin", { + to: "ga", + 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("beta"); + }); + + 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: "beta" }), + ); + await expect( + 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 new file mode 100644 index 000000000..39d62d602 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/promote/promote.ts @@ -0,0 +1,538 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { Command } from "commander"; +import { resolveManifestInDir } from "../manifest-resolve"; +import { isWithinDirectory } from "../sync/sync"; +import { shouldAllowJsManifestForDir } from "../trusted-js-manifest"; + +type Stability = "beta" | "ga"; + +const TIER_ORDER: Record = { + beta: 0, + ga: 1, +}; + +const IMPORT_PATH_MAP: Record = { + beta: "/beta", + ga: "", +}; + +/** Aligned with sync.ts and list.ts; keep all plugin-tree walks at the same cap. */ +const MAX_SCAN_DEPTH = 5; + +/** + * 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 === "beta" || value === "ga"; +} + +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 }; + } + + // 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.resolve( + pluginsDir, + pluginName, + "manifest.json", + ); + if ( + isWithinDirectory(manifestPath, pluginsDir) && + fs.existsSync(manifestPath) + ) { + return { manifestPath, isLocal: false }; + } + } + } + + return null; +} + +function scanDirForPlugin( + dir: string, + pluginName: string, + cwd: string, + depth: number, +): string | 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, { + allowJsManifest: allowJs, + }); + + if (resolved) { + try { + const obj = loadManifestFromFileSync(resolved.path); + if ( + obj && + typeof obj === "object" && + "name" in obj && + (obj as { name: string }).name === pluginName + ) { + return resolved.path; + } + } catch { + // skip unreadable / invalid manifest + } + 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 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 GA 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, +): { 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}`; + + // 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; + + if (!dryRun) { + fs.writeFileSync(filePath, updated); + } + + return { + file: filePath, + from: oldSuffix || "(root)", + to: newSuffix || "(root)", + }; +} + +function findTsFiles(dir: string, projectRoot: string, depth = 0): string[] { + if (depth >= MAX_SCAN_DEPTH) return []; + + const results: string[] = []; + 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 (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); + } + } + + return results; +} + +async function runPromote( + pluginName: string, + options: { + to: string; + dryRun?: boolean; + skipImports?: boolean; + skipSync?: boolean; + allowInstalled?: boolean; + }, +): Promise { + validatePluginName(pluginName); + + const cwd = process.cwd(); + + if (!isStability(options.to)) { + console.error( + `Invalid target tier "${options.to}". Must be one of: beta, ga.`, + ); + process.exit(1); + } + const target: Stability = options.to; + + const found = findPluginManifest(pluginName, cwd); + if (!found) { + console.error( + `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 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, ga (or omitted for ga).`, + ); + process.exit(1); + } + const currentStability: Stability = rawStability; + + 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] " : ""; + + if (target === "ga") { + 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})`, + ); + + 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, cwd); + for (const file of tsFiles) { + const result = rewriteImportsInFile( + file, + pluginName, + 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.`); + } + } + + if (!options.skipSync && !options.dryRun) { + // Monorepo-only: regenerate every artifact derived from plugin + // 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). + const generatorPath = path.join(cwd, "tools", "generate-plugin-entries.ts"); + if (fs.existsSync(generatorPath)) { + console.log(`\n${prefix}Regenerating manifest-derived artifacts...`); + const { execSync } = await import("node:child_process"); + try { + execSync("pnpm run generate:types", { cwd, stdio: "inherit" }); + } catch { + console.error( + `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); + } + } + + 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(syncCommand, { + cwd, + stdio: "inherit", + }); + } catch { + console.error( + `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); + } + } + + console.log( + `\n${prefix}Promotion complete: ${pluginName} ${currentStability} → ${target}`, + ); + if (importRewrites.length > 0) { + console.log(` ${importRewrites.length} file(s) with import rewrites`); + } +} + +/** 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") + .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") + .option( + "--allow-installed", + "Allow promoting a plugin that lives only under node_modules (advanced)", + ) + .action((pluginName, opts) => + runPromote(pluginName, opts).catch((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 2cfaff4ef..10258c81a 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -84,6 +84,15 @@ async function loadPluginEntry( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + // Narrowing on `!== "ga"` removes "ga"; the truthy check + // removes `undefined`. What's left is the non-GA tier set, + // which TypeScript already knows is assignable to TemplatePlugin's + // `stability` field — so no cast is needed and adding a future + // tier (e.g. "alpha") flows through type-correctly. + ...(manifest.stability && + manifest.stability !== "ga" && { + stability: manifest.stability, + }), }, ]; } @@ -413,6 +422,10 @@ async function scanForPlugins( ...(manifest.onSetupMessage && { onSetupMessage: manifest.onSetupMessage, }), + ...(manifest.stability && + manifest.stability !== "ga" && { + stability: manifest.stability, + }), } satisfies TemplatePlugin; } } @@ -525,7 +538,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 +765,17 @@ async function runPluginsSync(options: { } } + // Step 6b: Strip requiredByTemplate for non-GA plugins + for (const plugin of Object.values(plugins)) { + if ( + plugin.requiredByTemplate && + plugin.stability && + plugin.stability !== "ga" + ) { + plugin.requiredByTemplate = undefined; + } + } + if (!options.silent && !options.json) { console.log(`\nFound ${pluginCount} plugin(s):`); for (const [name, manifest] of Object.entries(plugins)) { @@ -766,6 +790,39 @@ 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]; + if (!oldPlugin || typeof oldPlugin !== "object") continue; + 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..dd30f27d3 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. 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" | "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 ed4ef5731..021c7f0bf 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": ["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 290edd059..47d385936 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": ["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", "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", diff --git a/template/server/server.ts b/template/server/server.ts index e28f3ef43..b33bb94d8 100644 --- a/template/server/server.ts +++ b/template/server/server.ts @@ -1,4 +1,17 @@ -import { createApp{{range $name, $_ := .plugins}}, {{$name}}{{end}} } from '@databricks/appkit'; +{{- $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'; +{{- if ne $betaImports "" }} +import { {{$betaImports}} } from '@databricks/appkit/beta'; +{{- end}} {{- if .plugins.lakebase}} import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes'; {{- end}} diff --git a/tools/generate-plugin-doc-banners.ts b/tools/generate-plugin-doc-banners.ts new file mode 100644 index 000000000..470729f6c --- /dev/null +++ b/tools/generate-plugin-doc-banners.ts @@ -0,0 +1,207 @@ +/** + * 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-GA. + * GA / 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"); + +/** + * 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 + * `.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" | "ga"; + 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 + } + + 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( + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "ga".`, + ); + } + + 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, + }); + } + + 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 === "ga" + ? 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 === "ga" ? "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(); diff --git a/tools/generate-plugin-entries.ts b/tools/generate-plugin-entries.ts new file mode 100644 index 000000000..f7517defa --- /dev/null +++ b/tools/generate-plugin-entries.ts @@ -0,0 +1,161 @@ +/** + * 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/ga-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 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. +// 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" | "ga"; +} + +/** + * 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[] = []; + + 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}`); + } + + // 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 ?? "ga"; + if (tier !== "ga" && tier !== "beta") { + throw new Error( + `Manifest at ${manifestPath} has invalid stability "${tier}". Must be "beta" or "ga".`, + ); + } + + infos.push({ + name: manifest.name, + folder: entry.name, + stability: tier, + }); + } + + // Deterministic order so re-runs produce reproducible 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 ga = infos.filter((p) => p.stability === "ga"); + const beta = infos.filter((p) => p.stability === "beta"); + + 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(GA_OUT); + formatWithBiome(BETA_OUT); + + console.log( + `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"})`, + ); +} + +main();