From 29222deff79d74bfb89f9a422d04888ec31e0c3d Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Wed, 25 Feb 2026 12:14:14 +0100 Subject: [PATCH] chore: introduce script to generate static app templates --- CONTRIBUTING.md | 14 ++ package.json | 1 + template/README.md | 43 +++- ...site.webmanifest => site.webmanifest.tmpl} | 0 .../src/pages/analytics/AnalyticsPage.tsx | 2 +- template/package.json | 2 +- template/playwright.config.ts | 4 +- template/tests/smoke.spec.ts | 74 ++++-- tools/generate-app-templates.ts | 215 ++++++++++++++++++ 9 files changed, 328 insertions(+), 27 deletions(-) rename template/client/public/{site.webmanifest => site.webmanifest.tmpl} (100%) create mode 100644 tools/generate-app-templates.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7058fb77..010d7bea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,20 @@ export DATABRICKS_APP_NAME=your-app-name # The name of the app to deploy. If not export DATABRICKS_WORKSPACE_DIR=your-workspace-dir # The source workspace directory to deploy the app from. It will be used to construct the absolute path: /Workspace/Users/{your-username}/{workspace-dir} ``` +## Generating App templates + +To generate app templates, run the following command: + +```bash +pnpm generate:app-templates +``` + +By default, the command will generate app templates in the `../app-templates` directory, assuming that you have the [`app-templates`](https://github.com/databricks/app-templates) repository cloned in the same parent directory as this one. + +You can override the output directory by setting the `APP_TEMPLATES_OUTPUT_DIR` environment variable. + +By default, the command will use the `databricks` CLI to generate the app templates. You can override the CLI by setting the `DATABRICKS_CLI` environment variable to provide a different binary name or path. + ## Contributing to AppKit documentation The `docs/` directory contains the AppKit documentation site, built with Docusaurus. diff --git a/package.json b/package.json index 9f557673..a2b2e388 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:watch": "pnpm -r --filter=!dev-playground --filter=!docs build:watch", "check:fix": "biome check --write .", "check": "biome check .", + "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", "deploy:playground": "pnpm pack:sdk && tsx tools/playground/deploy-playground.ts", diff --git a/template/README.md b/template/README.md index f20089b3..04fef545 100644 --- a/template/README.md +++ b/template/README.md @@ -1,6 +1,15 @@ -# Minimal Databricks App +# {{.projectName}} -A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, and Tailwind CSS. +A Databricks App powered by [AppKit](https://databricks.github.io/appkit/), featuring React, TypeScript, and Tailwind CSS. + +**Enabled plugins:** +{{- if .plugins.analytics}} +- **Analytics** -- SQL query execution against Databricks SQL Warehouses +{{- end}} +{{- if .plugins.lakebase}} +- **Lakebase** -- Fully managed Postgres database for transactional (OLTP) workloads on Databricks +{{- end}} +- **Server** -- Express HTTP server with static file serving and Vite dev mode ## Prerequisites @@ -15,7 +24,7 @@ A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScri For local development, configure your environment variables by creating a `.env` file: ```bash -cp env.example .env +cp .env.example .env ``` Edit `.env` and set the environment variables you need: @@ -25,6 +34,12 @@ DATABRICKS_HOST=https://your-workspace.cloud.databricks.com DATABRICKS_APP_PORT=8000 # ... other environment variables, depending on the plugins you use ``` +{{- if .plugins.lakebase}} + +#### Lakebase Configuration + +The Lakebase plugin requires additional environment variables for PostgreSQL connectivity. To learn how to configure the Lakebase plugin, see the [Lakebase plugin documentation](https://databricks.github.io/appkit/docs/plugins/lakebase). +{{- end}} ### CLI Authentication @@ -57,7 +72,7 @@ client_secret = prod-client-secret Deploy using a specific profile: ```bash -databricks bundle deploy -t prod --profile production +databricks bundle deploy --profile production ``` **Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. @@ -90,7 +105,7 @@ npm run build This creates: -- `dist/server/` - Compiled server code +- `dist/server.js` - Compiled server bundle - `client/dist/` - Bundled client assets ### Production @@ -126,12 +141,18 @@ Update `databricks.yml` with your workspace settings: ```yaml targets: - dev: + default: workspace: host: https://your-workspace.cloud.databricks.com +{{- if .plugins.analytics}} variables: - warehouse_id: your-warehouse-id + sql_warehouse_id: your-warehouse-id +{{- end}} ``` +{{- if .plugins.analytics}} + +Make sure to set the `sql_warehouse_id` variable to your Databricks SQL Warehouse ID. +{{- end}} ### 2. Validate Bundle @@ -141,10 +162,10 @@ databricks bundle validate ### 3. Deploy -Deploy to the development target: +Deploy to the default target: ```bash -databricks bundle deploy -t dev +databricks bundle deploy ``` ### 4. Run @@ -174,6 +195,10 @@ databricks bundle deploy -t prod * server.ts # Server entry point * routes/ # Routes * shared/ # Shared types +{{- if .plugins.analytics}} +* config/ # Configuration + * queries/ # SQL query files +{{- end}} * databricks.yml # Bundle configuration * app.yaml # App configuration * .env.example # Environment variables example diff --git a/template/client/public/site.webmanifest b/template/client/public/site.webmanifest.tmpl similarity index 100% rename from template/client/public/site.webmanifest rename to template/client/public/site.webmanifest.tmpl diff --git a/template/client/src/pages/analytics/AnalyticsPage.tsx b/template/client/src/pages/analytics/AnalyticsPage.tsx index e1ca7a9d..9f9a07f4 100644 --- a/template/client/src/pages/analytics/AnalyticsPage.tsx +++ b/template/client/src/pages/analytics/AnalyticsPage.tsx @@ -20,7 +20,7 @@ import { sql } from "@databricks/appkit-ui/js"; import { useState } from 'react'; export function AnalyticsPage() { - const { data, loading, error } = useAnalyticsQuery('hello_world', { + const { data, loading, error } = useAnalyticsQuery<{ value: string }[]>('hello_world', { message: sql.string('hello world'), }); diff --git a/template/package.json b/template/package.json index fa63d9c1..4e38c9dd 100644 --- a/template/package.json +++ b/template/package.json @@ -1,7 +1,7 @@ { "name": "{{.projectName}}", "version": "1.0.0", - "main": "build/index.js", + "main": "dist/server.js", "type": "module", "scripts": { "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server.js", diff --git a/template/playwright.config.ts b/template/playwright.config.ts index c4cad7a5..98bee655 100644 --- a/template/playwright.config.ts +++ b/template/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: `http://localhost:${process.env.PORT || 8000}`, + baseURL: `http://localhost:${process.env.DATABRICKS_APP_PORT || process.env.PORT || 8000}`, trace: 'on-first-retry', }, projects: [ @@ -19,7 +19,7 @@ export default defineConfig({ ], webServer: { command: 'npm run dev', - url: `http://localhost:${process.env.PORT || 8000}`, + url: `http://localhost:${process.env.DATABRICKS_APP_PORT || process.env.PORT || 8000}`, reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, diff --git a/template/tests/smoke.spec.ts b/template/tests/smoke.spec.ts index d712c695..8d080706 100644 --- a/template/tests/smoke.spec.ts +++ b/template/tests/smoke.spec.ts @@ -2,31 +2,77 @@ import { test, expect } from '@playwright/test'; import { writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; +// ── Templated configuration (resolved by `databricks apps init`) ──────────── +const APP_CONFIG = { + name: '{{.projectName}}', + plugins: [ +{{- if .plugins.analytics}} + 'analytics', +{{- end}} +{{- if .plugins.lakebase}} + 'lakebase', +{{- end}} + ], +} as const; + +interface PluginPage { + navLabel: string; + path: string; + expectedTexts: string[]; +} + +const PLUGIN_PAGES: Record = { + analytics: { + navLabel: 'Analytics', + path: '/analytics', + expectedTexts: ['SQL Query Result', 'Sales Data Filter'], + }, + lakebase: { + navLabel: 'Lakebase', + path: '/lakebase', + expectedTexts: ['Todo List'], + }, +}; + +const enabledPages = Object.entries(PLUGIN_PAGES).filter( + ([key]) => APP_CONFIG.plugins.includes(key), +); + +// ── Tests ─────────────────────────────────────────────────────────────────── + let testArtifactsDir: string; let consoleLogs: string[] = []; let consoleErrors: string[] = []; let pageErrors: string[] = []; let failedRequests: string[] = []; -test('smoke test - app loads and displays data', async ({ page }) => { - // Navigate to the app +test('smoke test - app loads and displays home page', async ({ page }) => { await page.goto('/'); - // ⚠️ UPDATE THESE SELECTORS after customizing App.tsx: - // - Change heading name to match your app title - // - Change data selector to match your primary data display - await expect(page.getByRole('heading', { name: 'Minimal Databricks App' })).toBeVisible(); - await expect(page.getByText('hello world', { exact: true })).toBeVisible({ timeout: 30000 }); - - // Wait for health check to complete (wait for "OK" status) - await expect(page.getByText('OK')).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('heading', { name: APP_CONFIG.name })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Welcome to your Databricks App' }), + ).toBeVisible(); + await expect(page.getByText('Getting Started')).toBeVisible(); - // Verify console logs were captured - expect(consoleLogs.length).toBeGreaterThan(0); - expect(consoleErrors.length).toBe(0); - expect(pageErrors.length).toBe(0); + await expect(page.getByRole('link', { name: 'Home' })).toBeVisible(); + for (const [, plugin] of enabledPages) { + await expect(page.getByRole('link', { name: plugin.navLabel })).toBeVisible(); + } }); +for (const [name, plugin] of enabledPages) { + test(`smoke test - ${name} page loads`, async ({ page }) => { + await page.goto(plugin.path); + + for (const text of plugin.expectedTexts) { + await expect(page.getByText(text)).toBeVisible(); + } + }); +} + +// ── Lifecycle hooks ───────────────────────────────────────────────────────── + test.beforeEach(async ({ page }) => { consoleLogs = []; consoleErrors = []; diff --git a/tools/generate-app-templates.ts b/tools/generate-app-templates.ts new file mode 100644 index 00000000..e2e59575 --- /dev/null +++ b/tools/generate-app-templates.ts @@ -0,0 +1,215 @@ +#!/usr/bin/env tsx + +/** + * Generates static app template variants using `databricks apps init` with the local template. + * Each entry in APP_TEMPLATES produces one output app in the output directory. + * + * Output directory: ../app-templates (relative to repo root) by default. + * Override with the APP_TEMPLATES_OUTPUT_DIR environment variable. + * + * The Databricks CLI binary defaults to "databricks". + * Override with the DATABRICKS_CLI environment variable (e.g. DATABRICKS_CLI=dbx). + * + * Usage: + * tsx tools/generate-app-templates.ts + * APP_TEMPLATES_OUTPUT_DIR=/tmp/my-apps tsx tools/generate-app-templates.ts + * DATABRICKS_CLI=dbx tsx tools/generate-app-templates.ts + */ + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +const ROOT = resolve(import.meta.dirname, ".."); + +const OUTPUT_DIR = process.env.APP_TEMPLATES_OUTPUT_DIR + ? resolve(process.env.APP_TEMPLATES_OUTPUT_DIR) + : resolve(ROOT, "../app-templates"); + +const TEMPLATE_PATH = join(ROOT, "template"); + +const DATABRICKS_CLI = process.env.DATABRICKS_CLI ?? "databricks"; + +const APPKIT_SECTION_START = ""; +const APPKIT_SECTION_END = ""; + +interface AppTemplate { + /** Output directory name and --name passed to databricks apps init */ + name: string; + /** Plugin features to enable (--features) */ + features: string[]; + /** Resource values as plugin.resourceKey.field → value (each becomes a --set flag) */ + set?: Record; + /** App description — passed to --description and used in the README table */ + description: string; +} + +const FEATURE_DEPENDENCIES: Record = { + analytics: "SQL warehouse", + lakebase: "Database", +}; + +const APP_TEMPLATES: AppTemplate[] = [ + { + name: "appkit-all-in-one", + features: ["analytics", "lakebase"], + set: { + "analytics.sql-warehouse.id": "placeholder", + }, + description: + "Full-stack Node.js app with SQL analytics dashboards and Lakebase Autoscaling (Postgres) CRUD", + }, + { + name: "appkit-analytics", + features: ["analytics"], + set: { + "analytics.sql-warehouse.id": "placeholder", + }, + description: "Node.js app with SQL analytics dashboards and charts", + }, + { + name: "appkit-lakebase", + features: ["lakebase"], + description: + "Node.js app with Lakebase Autoscaling (Postgres) CRUD operations", + }, +]; + +function run(cmd: string, args: string[]): number { + const result = spawnSync(cmd, args, { stdio: "inherit" }); + return result.status ?? 1; +} + +console.log(`Output directory: ${OUTPUT_DIR}\n`); +mkdirSync(OUTPUT_DIR, { recursive: true }); + +for (const app of APP_TEMPLATES) { + const appDir = join(OUTPUT_DIR, app.name); + + console.log(`\n── Generating ${app.name} ──`); + + // Remove existing output so databricks apps init doesn't complain the dir exists + rmSync(appDir, { recursive: true, force: true }); + + const args = [ + "apps", + "init", + "--template", + TEMPLATE_PATH, + "--name", + app.name, + "--features", + app.features.join(","), + "--output-dir", + OUTPUT_DIR, + ]; + + args.push("--description", app.description); + + for (const [key, value] of Object.entries(app.set ?? {})) { + args.push("--set", `${key}=${value}`); + } + + const status = run(DATABRICKS_CLI, args); + if (status !== 0) { + console.error(`\nFailed to generate ${app.name} (exit code ${status})`); + process.exit(status); + } + + postProcess(appDir, app); +} + +updateReadme(); + +console.log( + `\n✓ Generated ${APP_TEMPLATES.length} app templates in ${OUTPUT_DIR}`, +); + +/** + * Post-processes a generated template to clean it up for publishing: + * - Deletes .env (contains resolved credentials from the generator's CLI profile) + * - Filters appkit.plugins.json to only include the variant's enabled plugins + * - Replaces the resolved workspace host URL in databricks.yml with a placeholder + */ +function postProcess(appDir: string, app: AppTemplate): void { + console.log(` Post-processing ${app.name}...`); + + // 1. Delete .env — end users get it from `databricks apps init`, but published + // templates should not ship credentials from the generator's environment. + rmSync(join(appDir, ".env"), { force: true }); + + // 2. Filter appkit.plugins.json to only keep enabled features + server (always required). + const pluginsPath = join(appDir, "appkit.plugins.json"); + const pluginsJson = JSON.parse(readFileSync(pluginsPath, "utf-8")); + const keepPlugins = new Set([...app.features, "server"]); + for (const name of Object.keys(pluginsJson.plugins)) { + if (!keepPlugins.has(name)) { + delete pluginsJson.plugins[name]; + } + } + writeFileSync(pluginsPath, `${JSON.stringify(pluginsJson, null, 2)}\n`); + + // 3. Replace the resolved workspace host URL with a placeholder. + const databricksYmlPath = join(appDir, "databricks.yml"); + const yml = readFileSync(databricksYmlPath, "utf-8"); + const fixedYml = yml.replace( + /host:\s+https:\/\/\S+/g, + "host: https://your-workspace.cloud.databricks.com", + ); + writeFileSync(databricksYmlPath, fixedYml); +} + +/** + * Updates the AppKit section in the output directory's README.md. + * The section is delimited by HTML comment markers for idempotent replacement. + * If the markers don't exist yet, the section is appended at the end. + */ +function updateReadme(): void { + const readmePath = join(OUTPUT_DIR, "README.md"); + if (!existsSync(readmePath)) { + console.log(" Skipping README update (file not found)"); + return; + } + + console.log("\nUpdating AppKit section in README.md..."); + + const rows = APP_TEMPLATES.map((app) => { + const deps = app.features + .map((f) => FEATURE_DEPENDENCIES[f]) + .filter(Boolean) + .join(", "); + return `| \`${app.name}\` | ${app.description} | ${deps || "None"} |`; + }); + + const section = [ + APPKIT_SECTION_START, + "", + "| Template | Description | Dependencies |", + "|----------|-------------|--------------|", + ...rows, + "", + APPKIT_SECTION_END, + ].join("\n"); + + const readme = readFileSync(readmePath, "utf-8"); + const startIdx = readme.indexOf(APPKIT_SECTION_START); + const endIdx = readme.indexOf(APPKIT_SECTION_END); + + let updated: string; + if (startIdx !== -1 && endIdx !== -1) { + updated = + readme.slice(0, startIdx) + + section + + readme.slice(endIdx + APPKIT_SECTION_END.length); + } else { + updated = `${readme.trimEnd()}\n\n### AppKit\n\n${section}\n`; + } + + writeFileSync(readmePath, updated); +}