From f7fc7a5cf5f42dea1d90867e1726f7d4caf48055 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 23 Dec 2025 19:09:43 +0100 Subject: [PATCH 01/13] feat: new apps commands chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup chore: fixup --- cmd/experimental/experimental.go | 2 + cmd/workspace/apps/overrides.go | 3 + .../appkit/databricks_template_schema.json | 1 + .../template/{{.project_name}}/.env.tmpl | 2 +- .../config/queries/schema.ts | 28 - .../template/{{.project_name}}/package.json | 5 +- .../appkit/generic/.env.example.tmpl | 7 + .../templates/appkit/generic/.env.tmpl | 7 + .../templates/appkit/generic/.gitignore.tmpl | 9 + .../templates/appkit/generic/.prettierignore | 36 + .../templates/appkit/generic/.prettierrc.json | 12 + .../templates/appkit/generic/AGENTS.md | 1 + .../templates/appkit/generic/CLAUDE.md | 79 ++ .../templates/appkit/generic/README.md | 183 ++++ .../templates/appkit/generic/app.yaml.tmpl | 5 + .../appkit/generic/client/components.json | 21 + .../appkit/generic/client/index.html | 18 + .../appkit/generic/client/postcss.config.js | 6 + .../client/public/apple-touch-icon.png | Bin 0 -> 2547 bytes .../generic/client/public/favicon-16x16.png | Bin 0 -> 302 bytes .../generic/client/public/favicon-192x192.png | Bin 0 -> 2762 bytes .../generic/client/public/favicon-32x32.png | Bin 0 -> 492 bytes .../generic/client/public/favicon-48x48.png | Bin 0 -> 686 bytes .../generic/client/public/favicon-512x512.png | Bin 0 -> 10325 bytes .../appkit/generic/client/public/favicon.svg | 6 + .../generic/client/public/site.webmanifest | 19 + .../appkit/generic/client/src/App.tsx | 155 ++++ .../generic/client/src/ErrorBoundary.tsx | 75 ++ .../appkit/generic/client/src/index.css | 82 ++ .../appkit/generic/client/src/lib/utils.ts | 6 + .../appkit/generic/client/src/main.tsx | 13 + .../appkit/generic/client/src/vite-env.d.ts | 1 + .../appkit/generic/client/tailwind.config.ts | 10 + .../appkit/generic/client/vite.config.ts | 25 + .../generic/config/queries/hello_world.sql | 1 + .../generic/config/queries/mocked_sales.sql | 18 + .../appkit/generic/databricks.yml.tmpl | 36 + .../appkit/generic/docs/appkit-sdk.md | 86 ++ .../templates/appkit/generic/docs/frontend.md | 108 +++ .../appkit/generic/docs/sql-queries.md | 195 ++++ .../templates/appkit/generic/docs/testing.md | 58 ++ .../templates/appkit/generic/docs/trpc.md | 95 ++ .../templates/appkit/generic/eslint.config.js | 91 ++ .../generic/features/analytics/app_env.yml | 2 + .../features/analytics/bundle_resources.yml | 4 + .../features/analytics/bundle_variables.yml | 2 + .../generic/features/analytics/dotenv.yml | 1 + .../features/analytics/dotenv_example.yml | 1 + .../features/analytics/target_variables.yml | 1 + .../templates/appkit/generic/package.json | 79 ++ .../appkit/generic/playwright.config.ts | 26 + .../templates/appkit/generic/server/server.ts | 8 + .../appkit/generic/tests/smoke.spec.ts | 108 +++ .../appkit/generic/tsconfig.client.json | 28 + .../templates/appkit/generic/tsconfig.json | 4 + .../appkit/generic/tsconfig.server.json | 17 + .../appkit/generic/tsconfig.shared.json | 21 + .../templates/appkit/generic/vitest.config.ts | 15 + experimental/dev/cmd/app/app.go | 24 + experimental/dev/cmd/app/deploy.go | 228 +++++ experimental/dev/cmd/app/dev_remote.go | 243 +++++ experimental/dev/cmd/app/dev_remote_test.go | 90 ++ experimental/dev/cmd/app/features.go | 210 +++++ experimental/dev/cmd/app/features_test.go | 250 ++++++ experimental/dev/cmd/app/import.go | 265 ++++++ experimental/dev/cmd/app/init.go | 837 +++++++++++++++++ experimental/dev/cmd/app/init_test.go | 238 +++++ experimental/dev/cmd/app/prompt.go | 419 +++++++++ experimental/dev/cmd/app/prompt_test.go | 187 ++++ experimental/dev/cmd/app/vite-server.js | 172 ++++ experimental/dev/cmd/app/vite_bridge.go | 838 ++++++++++++++++++ experimental/dev/cmd/app/vite_bridge_test.go | 370 ++++++++ experimental/dev/cmd/dev.go | 21 + go.mod | 26 + go.sum | 64 ++ 75 files changed, 6273 insertions(+), 31 deletions(-) delete mode 100644 experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl create mode 100644 experimental/apps-mcp/templates/appkit/generic/.env.tmpl create mode 100644 experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl create mode 100644 experimental/apps-mcp/templates/appkit/generic/.prettierignore create mode 100644 experimental/apps-mcp/templates/appkit/generic/.prettierrc.json create mode 120000 experimental/apps-mcp/templates/appkit/generic/AGENTS.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/CLAUDE.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/README.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/components.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/index.html create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-192x192.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-32x32.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-48x48.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/index.css create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql create mode 100644 experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql create mode 100644 experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl create mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/frontend.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/testing.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/trpc.md create mode 100644 experimental/apps-mcp/templates/appkit/generic/eslint.config.js create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml create mode 100644 experimental/apps-mcp/templates/appkit/generic/package.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/playwright.config.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/server/server.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts create mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json create mode 100644 experimental/apps-mcp/templates/appkit/generic/vitest.config.ts create mode 100644 experimental/dev/cmd/app/app.go create mode 100644 experimental/dev/cmd/app/deploy.go create mode 100644 experimental/dev/cmd/app/dev_remote.go create mode 100644 experimental/dev/cmd/app/dev_remote_test.go create mode 100644 experimental/dev/cmd/app/features.go create mode 100644 experimental/dev/cmd/app/features_test.go create mode 100644 experimental/dev/cmd/app/import.go create mode 100644 experimental/dev/cmd/app/init.go create mode 100644 experimental/dev/cmd/app/init_test.go create mode 100644 experimental/dev/cmd/app/prompt.go create mode 100644 experimental/dev/cmd/app/prompt_test.go create mode 100644 experimental/dev/cmd/app/vite-server.js create mode 100644 experimental/dev/cmd/app/vite_bridge.go create mode 100644 experimental/dev/cmd/app/vite_bridge_test.go create mode 100644 experimental/dev/cmd/dev.go diff --git a/cmd/experimental/experimental.go b/cmd/experimental/experimental.go index 7e7d376fea..92c9369f7a 100644 --- a/cmd/experimental/experimental.go +++ b/cmd/experimental/experimental.go @@ -2,6 +2,7 @@ package experimental import ( mcp "github.com/databricks/cli/experimental/aitools/cmd" + "github.com/databricks/cli/experimental/dev/cmd" "github.com/spf13/cobra" ) @@ -20,6 +21,7 @@ These commands provide early access to new features that are still under development. They may change or be removed in future versions without notice.`, } + cmd.AddCommand(dev.New()) cmd.AddCommand(mcp.NewMcpCmd()) return cmd diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index a1e35da903..34a9cd8ee9 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -23,6 +23,9 @@ func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsR } func createOverride(createCmd *cobra.Command, createReq *apps.CreateAppRequest) { + createCmd.Short = `Create an app in your workspace.` + createCmd.Long = `Create an app in your workspace.` + originalRunE := createCmd.RunE createCmd.RunE = func(cmd *cobra.Command, args []string) error { err := originalRunE(cmd, args) diff --git a/experimental/aitools/templates/appkit/databricks_template_schema.json b/experimental/aitools/templates/appkit/databricks_template_schema.json index 973dab9e04..43c6c5a356 100644 --- a/experimental/aitools/templates/appkit/databricks_template_schema.json +++ b/experimental/aitools/templates/appkit/databricks_template_schema.json @@ -10,6 +10,7 @@ "sql_warehouse_id": { "type": "string", "description": "SQL Warehouse ID", + "default": "", "order": 2 }, "profile": { diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl b/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl index be54897988..8599a13ed5 100644 --- a/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl +++ b/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl @@ -1,5 +1,5 @@ {{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME=minimal +DATABRICKS_APP_NAME={{.project_name}} FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts b/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts deleted file mode 100644 index 1abc1821d7..0000000000 --- a/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Query Result Schemas - Define the COLUMNS RETURNED by each SQL query. - * - * These schemas validate QUERY RESULTS, not input parameters. - * - Input parameters are passed to useAnalyticsQuery() as the second argument - * - These schemas define the shape of data[] returned by the query - * - * Example: - * SQL: SELECT name, age FROM users WHERE city = :city - * Schema: z.array(z.object({ name: z.string(), age: z.number() })) - * Usage: useAnalyticsQuery('users', { city: sql.string('NYC') }) - * ^ input params ^ schema validates this result - */ - -import { z } from 'zod'; -export const querySchemas = { - mocked_sales: z.array( - z.object({ - max_month_num: z.number().min(1).max(12), - }) - ), - - hello_world: z.array( - z.object({ - value: z.string(), - }) - ), -}; diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json b/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json index 480d310043..a26a2dcd2b 100644 --- a/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json +++ b/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", - "lint:ast-grep": "tsx scripts/lint-ast-grep.ts", + "lint:ast-grep": "appkit-lint", "format": "prettier --check .", "format:fix": "prettier --write .", "test": "vitest run && npm run test:smoke", @@ -20,7 +20,8 @@ "test:e2e:ui": "playwright test --ui", "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", - "typegen": "tsx scripts/generate-types.ts" + "typegen": "appkit-generate-types", + "setup": "appkit-setup --write" }, "keywords": [], "author": "", diff --git a/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl b/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl new file mode 100644 index 0000000000..c8b5c44196 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl @@ -0,0 +1,7 @@ +DATABRICKS_HOST=https://... +{{- if .dotenv_example}} +{{.dotenv_example}} +{{- end}} +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME=minimal +FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/apps-mcp/templates/appkit/generic/.env.tmpl b/experimental/apps-mcp/templates/appkit/generic/.env.tmpl new file mode 100644 index 0000000000..62f5518744 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/.env.tmpl @@ -0,0 +1,7 @@ +{{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} +{{- if .dotenv}} +{{.dotenv}} +{{- end}} +DATABRICKS_APP_PORT=8000 +DATABRICKS_APP_NAME={{.project_name}} +FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl b/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl new file mode 100644 index 0000000000..0cc1c63286 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl @@ -0,0 +1,9 @@ +node_modules/ +client/dist/ +dist/ +build/ +.env +.databricks/ +.smoke-test/ +test-results/ +playwright-report/ diff --git a/experimental/apps-mcp/templates/appkit/generic/.prettierignore b/experimental/apps-mcp/templates/appkit/generic/.prettierignore new file mode 100644 index 0000000000..7d3d77c695 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/.prettierignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules + +# Build outputs +dist +build +client/dist +.next +.databricks/ + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Coverage +coverage + +# Cache +.cache +.turbo + +# Lock files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Vendor +vendor diff --git a/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json b/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json new file mode 100644 index 0000000000..d95a63f69c --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "jsxSingleQuote": false +} diff --git a/experimental/apps-mcp/templates/appkit/generic/AGENTS.md b/experimental/apps-mcp/templates/appkit/generic/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md b/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md new file mode 100644 index 0000000000..b3c32fe33f --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md @@ -0,0 +1,79 @@ +TypeScript full-stack template powered by **Databricks AppKit** with tRPC for additional custom API endpoints. + +- server/: Node.js backend with App Kit and tRPC +- client/: React frontend with App Kit hooks and tRPC client +- config/queries/: SQL query files for analytics +- shared/: Shared TypeScript types +- docs/: Detailed documentation on using App Kit features + +## Quick Start: Your First Query & Chart + +Follow these 3 steps to add data visualization to your app: + +**Step 1: Create a SQL query file** + +```sql +-- config/queries/my_data.sql +SELECT category, COUNT(*) as count, AVG(value) as avg_value +FROM my_table +GROUP BY category +``` + +**Step 2: Define the schema** + +```typescript +// config/queries/schema.ts +export const querySchemas = { + my_data: z.array( + z.object({ + category: z.string(), + count: z.number(), + avg_value: z.number(), + }) + ), +}; +``` + +**Step 3: Add visualization to your app** + +```typescript +// client/src/App.tsx +import { BarChart } from '@databricks/appkit-ui/react'; + + +``` + +**That's it!** The component handles data fetching, loading states, and rendering automatically. + +**To refresh TypeScript types after adding queries:** +- Run `npm run typegen` OR run `npm run dev` - both auto-generate type definitions in `client/src/appKitTypes.d.ts` +- DO NOT manually edit `appKitTypes.d.ts` + +## Installation + +**IMPORTANT**: When running `npm install`, always use `required_permissions: ['all']` to avoid sandbox permission errors. + +## NPM Scripts + +### Development +- `npm run dev` - Start dev server with hot reload (**ALWAYS use during development**) + +### Testing and Code Quality +See the databricks experimental apps-mcp tools validate instead of running these individually. + +### Utility +- `npm run clean` - Remove all build artifacts and node_modules + +**Common workflows:** +- Development: `npm run dev` → make changes → `npm run typecheck` → `npm run lint:fix` +- Pre-deploy: Validate with `databricks experimental apps-mcp tools validate .` + +## Documentation + +**IMPORTANT**: Read the relevant docs below before implementing features. They contain critical information about common pitfalls (e.g., SQL numeric type handling, schema definitions, Radix UI constraints). + +- [SQL Queries](docs/sql-queries.md) - query files, schemas, type handling, parameterization +- [App Kit SDK](docs/appkit-sdk.md) - TypeScript imports, server setup, useAnalyticsQuery hook +- [Frontend](docs/frontend.md) - visualization components, styling, layout, Radix constraints +- [tRPC](docs/trpc.md) - custom endpoints for non-SQL operations (mutations, Databricks APIs) +- [Testing](docs/testing.md) - vitest unit tests, Playwright smoke/E2E tests diff --git a/experimental/apps-mcp/templates/appkit/generic/README.md b/experimental/apps-mcp/templates/appkit/generic/README.md new file mode 100644 index 0000000000..a39fb95c2a --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/README.md @@ -0,0 +1,183 @@ +# Minimal Databricks App + +A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, tRPC, and Tailwind CSS. + +## Prerequisites + +- Node.js 18+ and npm +- Databricks CLI (for deployment) +- Access to a Databricks workspace + +## Databricks Authentication + +### Local Development + +For local development, configure your environment variables by creating a `.env` file: + +```bash +cp env.example .env +``` + +Edit `.env` and set the following: + +```env +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com +DATABRICKS_WAREHOUSE_ID=your-warehouse-id +DATABRICKS_APP_PORT=8000 +``` + +### CLI Authentication + +The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: + +#### OAuth U2M + +Interactive browser-based authentication with short-lived tokens: + +```bash +databricks auth login --host https://your-workspace.cloud.databricks.com +``` + +This will open your browser to complete authentication. The CLI saves credentials to `~/.databrickscfg`. + +#### Configuration Profiles + +Use multiple profiles for different workspaces: + +```ini +[DEFAULT] +host = https://dev-workspace.cloud.databricks.com + +[production] +host = https://prod-workspace.cloud.databricks.com +client_id = prod-client-id +client_secret = prod-client-secret +``` + +Deploy using a specific profile: + +```bash +databricks bundle deploy -t prod --profile production +``` + +**Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. + +## Getting Started + +### Install Dependencies + +```bash +npm install +``` + +### Development + +Run the app in development mode with hot reload: + +```bash +npm run dev +``` + +The app will be available at the URL shown in the console output. + +### Build + +Build both client and server for production: + +```bash +npm run build +``` + +This creates: + +- `dist/server/` - Compiled server code +- `client/dist/` - Bundled client assets + +### Production + +Run the production build: + +```bash +npm start +``` + +## Code Quality + +```bash +# Type checking +npm run typecheck + +# Linting +npm run lint +npm run lint:fix + +# Formatting +npm run format +npm run format:fix +``` + +## Deployment with Databricks Asset Bundles + +### 1. Configure Bundle + +Update `databricks.yml` with your workspace settings: + +```yaml +targets: + dev: + workspace: + host: https://your-workspace.cloud.databricks.com + variables: + warehouse_id: your-warehouse-id +``` + +### 2. Validate Bundle + +```bash +databricks bundle validate +``` + +### 3. Deploy + +Deploy to the development target: + +```bash +databricks bundle deploy -t dev +``` + +### 4. Run + +Start the deployed app: + +```bash +databricks bundle run -t dev +``` + +### Deploy to Production + +1. Configure the production target in `databricks.yml` +2. Deploy to production: + +```bash +databricks bundle deploy -t prod +``` + +## Project Structure + +``` +* client/ # React frontend + * src/ # Source code + * public/ # Static assets +* server/ # Express backend + * server.ts # Server entry point + * trpc.ts # tRPC router +* shared/ # Shared types +* databricks.yml # Bundle configuration +``` + +## Tech Stack + +- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS +- **Backend**: Node.js, Express, tRPC +- **UI Components**: Radix UI, shadcn/ui +- **Databricks**: App Kit SDK, Analytics SDK diff --git a/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl b/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl new file mode 100644 index 0000000000..a4e7b27d01 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl @@ -0,0 +1,5 @@ +command: ['npm', 'run', 'start'] +{{- if .app_env}} +env: +{{.app_env}} +{{- end}} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/components.json b/experimental/apps-mcp/templates/appkit/generic/client/components.json new file mode 100644 index 0000000000..13e1db0b7a --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/index.html b/experimental/apps-mcp/templates/appkit/generic/client/index.html new file mode 100644 index 0000000000..4b3117fd79 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Created by Apps MCP + + +
+ + + diff --git a/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js b/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js new file mode 100644 index 0000000000..51a6e4e62b --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png b/experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32053bd2d17ca6d4cdcfa2e2390cdc4db61658c1 GIT binary patch literal 2547 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 literal 0 HcmV?d00001 diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c16eb471f63a06f45119cebeb085dcc6ce3252 GIT binary patch literal 302 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 Uz+N|&sQ>@~07*qoM6N<$f;65h(*OVf literal 0 HcmV?d00001 diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..14d7d209db562940b4e78d23fce203d69afe7c38 GIT binary patch literal 10325 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 boicPbM8)qSin-XseaGC)+VuG$w_E=Y^dlWB literal 0 HcmV?d00001 diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg new file mode 100644 index 0000000000..cb30c1e05b --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest b/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest new file mode 100644 index 0000000000..db531d91ce --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Minimal Databricks App", + "short_name": "Minimal App", + "icons": [ + { + "src": "/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx new file mode 100644 index 0000000000..12cd542aff --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx @@ -0,0 +1,155 @@ +import { + useAnalyticsQuery, + AreaChart, + LineChart, + RadarChart, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@databricks/appkit-ui/react'; +import { sql } from "@databricks/appkit-ui/js"; +import { useState, useEffect } from 'react'; + +function App() { + const { data, loading, error } = useAnalyticsQuery('hello_world', { + message: sql.string('hello world'), + }); + + const [health, setHealth] = useState<{ + status: string; + timestamp: string; + } | null>(null); + const [healthError, setHealthError] = useState(null); + + useEffect(() => { + fetch('/health') + .then((response) => response.json()) + .then((data: { status: string }) => setHealth({ ...data, timestamp: new Date().toISOString() })) + .catch((error: { message: string }) => setHealthError(error.message)); + }, []); + + const [maxMonthNum, setMaxMonthNum] = useState(12); + + const salesParameters = { max_month_num: sql.number(maxMonthNum) }; + + return ( +
+
+

Minimal Databricks App

+

A minimal Databricks App powered by Databricks AppKit

+
+ +
+ + + SQL Query Result + + + {loading && ( +
+ + +
+ )} + {error &&
Error: {error}
} + {data && data.length > 0 && ( +
+
Query: SELECT :message AS value
+
{data[0].value}
+
+ )} + {data && data.length === 0 &&
No results
} +
+
+ + + + Health Check + + + {!health && !healthError && ( +
+ + +
+ )} + {healthError && ( +
Error: {healthError}
+ )} + {health && ( +
+
+
+
{health.status.toUpperCase()}
+
+
+ Last checked: {new Date(health.timestamp).toLocaleString()} +
+
+ )} +
+
+ + + + Sales Data Filter + + +
+
+ + +
+
+
+
+ + + + Sales Trend Area Chart + + + + + + + + Sales Trend Custom Line Chart + + + + + + + + Sales Trend Radar Chart + + + + + +
+
+ ); +} + +export default App; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx new file mode 100644 index 0000000000..6a73c26ce4 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import type { ReactNode } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error); + console.error('Error details:', errorInfo); + this.setState({ + error, + errorInfo, + }); + } + + render() { + if (this.state.hasError) { + return ( +
+ + + Application Error + + +
+
+

Error Message:

+
{this.state.error?.toString()}
+
+ {this.state.errorInfo && ( +
+

Component Stack:

+
+                      {this.state.errorInfo.componentStack}
+                    
+
+ )} + {this.state.error?.stack && ( +
+

Stack Trace:

+
{this.state.error.stack}
+
+ )} +
+
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/index.css b/experimental/apps-mcp/templates/appkit/generic/client/src/index.css new file mode 100644 index 0000000000..0ce57a7dfe --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/index.css @@ -0,0 +1,82 @@ +@import "@databricks/appkit-ui/styles.css"; + +:root { + /* --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.603 0.135 166.892); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.795 0.157 78.748); + --warning-foreground: oklch(0.199 0.027 238.732); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); */ +} + +@media (prefers-color-scheme: dark) { + :root { + /* --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.67 0.12 167); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.83 0.165 85); + --warning-foreground: oklch(0.199 0.027 238.732); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); */ + } +} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts b/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts new file mode 100644 index 0000000000..2819a830d2 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx new file mode 100644 index 0000000000..35c59a58fe --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; +import { ErrorBoundary } from './ErrorBoundary.tsx'; + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts b/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts b/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts new file mode 100644 index 0000000000..31dece86e9 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'tailwindcss'; +import tailwindcssAnimate from 'tailwindcss-animate'; + +const config: Config = { + darkMode: ['class', 'media'], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + plugins: [tailwindcssAnimate], +}; + +export default config; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts b/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts new file mode 100644 index 0000000000..b49d4055d1 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import path from 'node:path'; + +// https://vite.dev/config/ +export default defineConfig({ + root: __dirname, + plugins: [react(), tailwindcss()], + server: { + middlewareMode: true, + }, + build: { + outDir: path.resolve(__dirname, './dist'), + emptyOutDir: true, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'recharts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql b/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql new file mode 100644 index 0000000000..31e480f726 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql @@ -0,0 +1 @@ +SELECT :message AS value; diff --git a/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql b/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql new file mode 100644 index 0000000000..868e94724f --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql @@ -0,0 +1,18 @@ +WITH mock_data AS ( + SELECT 'January' AS month, 1 AS month_num, 65000 AS revenue, 45000 AS expenses, 850 AS customers + UNION ALL SELECT 'February', 2, 72000, 48000, 920 + UNION ALL SELECT 'March', 3, 78000, 52000, 1050 + UNION ALL SELECT 'April', 4, 85000, 55000, 1180 + UNION ALL SELECT 'May', 5, 92000, 58000, 1320 + UNION ALL SELECT 'June', 6, 88000, 54000, 1250 + UNION ALL SELECT 'July', 7, 95000, 60000, 1380 + UNION ALL SELECT 'August', 8, 89000, 56000, 1290 + UNION ALL SELECT 'September', 9, 82000, 53000, 1150 + UNION ALL SELECT 'October', 10, 87000, 55000, 1220 + UNION ALL SELECT 'November', 11, 93000, 59000, 1340 + UNION ALL SELECT 'December', 12, 98000, 62000, 1420 +) +SELECT * +FROM mock_data +WHERE month_num <= :max_month_num +ORDER BY month_num; diff --git a/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl b/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl new file mode 100644 index 0000000000..711165a343 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl @@ -0,0 +1,36 @@ +bundle: + name: {{.project_name}} +{{if .bundle_variables}} + +variables: +{{.bundle_variables}} +{{- end}} + +resources: + apps: + app: + name: "{{.project_name}}" + description: "{{.app_description}}" + source_code_path: ./ + + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files + # user_api_scopes: + # - sql +{{if .bundle_resources}} + + # The resources which this app has access to. + resources: +{{.bundle_resources}} +{{- end}} + +targets: + prod: + # mode: production + default: true + workspace: + host: {{workspace_host}} +{{if .target_variables}} + + variables: +{{.target_variables}} +{{- end}} diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md b/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md new file mode 100644 index 0000000000..5ab00768e1 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md @@ -0,0 +1,86 @@ +# Databricks App Kit SDK + +## TypeScript Import Rules + +This template uses strict TypeScript settings with `verbatimModuleSyntax: true`. **Always use `import type` for type-only imports**. + +Template enforces `noUnusedLocals` - remove unused imports immediately or build fails. + +```typescript +// āœ… CORRECT - use import type for types +import type { MyInterface, MyType } from '../../shared/types'; + +// āŒ WRONG - will fail compilation +import { MyInterface, MyType } from '../../shared/types'; +``` + +## Server Setup + +```typescript +import { createApp, server, analytics } from '@databricks/app-kit'; + +const app = await createApp({ + plugins: [ + server({ autoStart: false }), + analytics(), + ], +}); + +// Extend with custom tRPC endpoints if needed +app.server.extend((express: Application) => { + express.use('/trpc', [appRouterMiddleware()]); +}); + +await app.server.start(); +``` + +## useAnalyticsQuery Hook + +**ONLY use when displaying data in a custom way that isn't a chart or table.** + +Use cases: +- Custom HTML layouts (cards, lists, grids) +- Summary statistics and KPIs +- Conditional rendering based on data values +- Data that needs transformation before display + +```typescript +import { useAnalyticsQuery, Skeleton } from '@databricks/app-kit-ui/react'; + +interface QueryResult { column_name: string; value: number; } + +function CustomDisplay() { + const { data, loading, error } = useAnalyticsQuery('query_name', { + start_date: sql.date(Date.now()), + category: sql.string("tools") + }); + + if (loading) return ; + if (error) return
Error: {error}
; + + return ( +
+ {data?.map(row => ( +
+

{row.column_name}

+

{row.value}

+
+ ))} +
+ ); +} +``` + +**API:** + +```typescript +const { data, loading, error } = useAnalyticsQuery( + queryName: string, // SQL file name without .sql extension + params: Record // Query parameters +); +// Returns: { data: T | null, loading: boolean, error: string | null } +``` + +**NOT supported:** +- `enabled` - Query always executes on mount. Use conditional rendering: `{selectedId && }` +- `refetch` - Not available. Re-mount component to re-query. diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md b/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md new file mode 100644 index 0000000000..a270b46b9e --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md @@ -0,0 +1,108 @@ +# Frontend Guidelines + +## Visualization Components + +Components from `@databricks/appkit-ui/react` handle data fetching, loading states, and error handling internally. + +Available: `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DataTable` + +**Basic Usage:** + +```typescript +import { BarChart, LineChart, DataTable, Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; +import { sql } from "@databricks/appkit-ui/js"; + +function MyDashboard() { + return ( +
+ + Sales by Region + + + + + + + Revenue Trend + + + + +
+ ); +} +``` + +Components automatically fetch data, show loading states, display errors, and render with sensible defaults. + +**Custom Visualization (Recharts):** + +```typescript +import { BarChart } from '@databricks/appkit-ui/react'; +import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; + + + + + + + + + + +``` + +Databricks brand colors: `['#40d1f5', '#4462c9', '#EB1600', '#0B2026', '#4A4A4A', '#353a4a']` + +**āŒ Don't double-fetch:** + +```typescript +// WRONG - redundant fetch +const { data } = useAnalyticsQuery('sales_data', {}); +return ; + +// CORRECT - let component handle it +return ; +``` + +## Layout Structure + +```tsx +
+

Page Title

+
{/* form inputs */}
+
{/* list items */}
+
+``` + +## Component Organization + +- Shared UI components: `@databricks/appkit-ui/react` +- Feature components: `client/src/components/FeatureName.tsx` +- Split components when logic exceeds ~100 lines or component is reused + +## Radix UI Constraints + +- `SelectItem` cannot have `value=""`. Use sentinel value like `"all"` for "show all" options. + +## Map Libraries (react-leaflet) + +For maps with React 19, use react-leaflet v5: + +```bash +npm install react-leaflet@^5.0.0 leaflet @types/leaflet +``` + +```typescript +import 'leaflet/dist/leaflet.css'; +``` + +## Best Practices + +- Use shadcn/radix components (Button, Input, Card, etc.) for consistent UI, import them from `@databricks/appkit-ui/react`. +- **Use skeleton loaders**: Always use `` components instead of plain "Loading..." text +- Define result types in `shared/types.ts` for reuse between frontend and backend +- Handle nullable fields: `value={field || ''}` for inputs +- Type callbacks explicitly: `onChange={(e: React.ChangeEvent) => ...}` +- Forms should have loading states: `disabled={isLoading}` +- Show empty states with helpful text when no data exists diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md b/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md new file mode 100644 index 0000000000..2db77f0bfb --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md @@ -0,0 +1,195 @@ +# SQL Query Files + +**IMPORTANT**: ALWAYS use SQL files in `config/queries/` for data retrieval. NEVER use tRPC for SQL queries. + +- Store ALL SQL queries in `config/queries/` directory +- Name files descriptively: `trip_statistics.sql`, `user_metrics.sql`, `sales_by_region.sql` +- Reference by filename (without extension) in `useAnalyticsQuery` or directly in a visualization component passing it as `queryKey` +- App Kit automatically executes queries against configured Databricks warehouse +- Benefits: Built-in caching, proper connection pooling, better performance + +## Query Schemas + +Define the shape of QUERY RESULTS (not input parameters) in `config/queries/schema.ts` using Zod schemas. + +- **These schemas validate the COLUMNS RETURNED by SQL queries** +- Input parameters are passed separately to `useAnalyticsQuery()` as the second argument +- Schema field names must match your SQL SELECT column names/aliases + +Example: + +```typescript +import { z } from 'zod'; + +export const querySchemas = { + mocked_sales: z.array( + z.object({ + max_month_num: z.number().min(1).max(12), + }) + ), + + hello_world: z.array( + z.object({ + value: z.string(), + }) + ), +}; +``` + +**IMPORTANT: Refreshing Type Definitions** + +After adding or modifying query schemas in `config/queries/schema.ts`: + +1. **DO NOT** manually edit `client/src/appKitTypes.d.ts` - this file is auto-generated +2. Run `npm run dev` to automatically regenerate the TypeScript type definitions +3. The dev server will scan your SQL files and schema definitions and update `appKitTypes.d.ts` accordingly + +## SQL Type Handling (Critical) + +**ALL numeric values from Databricks SQL are returned as STRINGS in JSON responses.** This includes results from `ROUND()`, `AVG()`, `SUM()`, `COUNT()`, etc. Always convert before using numeric methods: + +```typescript +// āŒ WRONG - fails at runtime +{row.total_amount.toFixed(2)} + +// āœ… CORRECT - convert to number first +{Number(row.total_amount).toFixed(2)} +``` + +**Helper Functions:** + +Use the helpers from `shared/types.ts` for consistent formatting: + +```typescript +import { toNumber, formatCurrency, formatPercent } from '../../shared/types'; + +// Convert to number +const amount = toNumber(row.amount); // "123.45" → 123.45 + +// Format as currency +const formatted = formatCurrency(row.amount); // "123.45" → "$123.45" + +// Format as percentage +const percent = formatPercent(row.rate); // "85.5" → "85.5%" +``` + +## Query Parameterization + +SQL queries can accept parameters to make them dynamic and reusable. + +**Key Points:** +- Parameters use colon prefix: `:parameter_name` +- Databricks infers types from values automatically +- For optional string parameters, use pattern: `(:param = '' OR column = :param)` +- **For optional date parameters, use sentinel dates** (`'1900-01-01'` and `'9999-12-31'`) instead of empty strings + +### SQL Parameter Syntax + +```sql +-- config/queries/filtered_data.sql +SELECT * +FROM my_table +WHERE column_value >= :min_value + AND column_value <= :max_value + AND category = :category + AND (:optional_filter = '' OR status = :optional_filter) +``` + +### Frontend Parameter Passing + +```typescript +import { sql } from "@databricks/appkit-ui/js"; + +const { data } = useAnalyticsQuery('filtered_data', { + min_value: sql.number(minValue), + max_value: sql.number(maxValue), + category: sql.string(category), + optional_filter: sql.string(optionalFilter || ''), // empty string for optional params +}); +``` + +### Date Parameters + +Use `sql.date()` for date parameters with `YYYY-MM-DD` format strings. + +**Frontend - Using Date Parameters:** + +```typescript +import { sql } from '@databricks/appkit-ui/js'; +import { useState } from 'react'; + +function MyComponent() { + const [startDate, setStartDate] = useState('2016-02-01'); + const [endDate, setEndDate] = useState('2016-02-29'); + + const queryParams = { + start_date: sql.date(startDate), // Pass YYYY-MM-DD string to sql.date() + end_date: sql.date(endDate), + }; + + const { data } = useAnalyticsQuery('my_query', queryParams); + + // ... +} +``` + +**SQL - Date Filtering:** + +```sql +-- Filter by date range using DATE() function +SELECT COUNT(*) as trip_count +FROM samples.nyctaxi.trips +WHERE DATE(tpep_pickup_datetime) >= :start_date + AND DATE(tpep_pickup_datetime) <= :end_date +``` + +**Date Helper Functions:** + +```typescript +// Helper to get dates relative to today +const daysAgo = (n: number) => { + const date = new Date(Date.now() - n * 86400000); + return sql.date(date) +}; + +const params = { + start_date: daysAgo(7), // 7 days ago + end_date: sql.date(daysAgo(0)), // Today +}; +``` + +### Optional Date Parameters - Use Sentinel Dates + +Databricks App Kit validates parameter types before query execution. **DO NOT use empty strings (`''`) for optional date parameters** as this causes validation errors. + +**āœ… CORRECT - Use Sentinel Dates:** + +```typescript +// Frontend: Use sentinel dates for "no filter" instead of empty strings +const revenueParams = { + group_by: 'month', + start_date: sql.date('1900-01-01'), // Sentinel: effectively no lower bound + end_date: sql.date('9999-12-31'), // Sentinel: effectively no upper bound + country: sql.string(country || ''), + property_type: sql.string(propertyType || ''), +}; +``` + +```sql +-- SQL: Simple comparison since sentinel dates are always valid +WHERE b.check_in >= CAST(:start_date AS DATE) + AND b.check_in <= CAST(:end_date AS DATE) +``` + +**Why Sentinel Dates Work:** +- `1900-01-01` is before any real data (effectively no lower bound filter) +- `9999-12-31` is after any real data (effectively no upper bound filter) +- Always valid DATE types, so no parameter validation errors +- All real dates fall within this range, so no filtering occurs + +**Parameter Types Summary:** +- ALWAYS use sql.* helper functions from the `@databricks/appkit-ui/js` package to define SQL parameters +- **Strings/Numbers**: Use directly in SQL with `:param_name` +- **Dates**: Use with `CAST(:param AS DATE)` in SQL +- **Optional Strings**: Use empty string default, check with `(:param = '' OR column = :param)` +- **Optional Dates**: Use sentinel dates (`sql.date('1900-01-01')` and `sql.date('9999-12-31')`) instead of empty strings diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/testing.md b/experimental/apps-mcp/templates/appkit/generic/docs/testing.md new file mode 100644 index 0000000000..b1a4fea219 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/docs/testing.md @@ -0,0 +1,58 @@ +# Testing Guidelines + +## Unit Tests (Vitest) + +**CRITICAL**: Use vitest for all tests. Put tests next to the code (e.g. src/\*.test.ts) + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('Feature Name', () => { + it('should do something', () => { + expect(true).toBe(true); + }); + + it('should handle async operations', async () => { + const result = await someAsyncFunction(); + expect(result).toBeDefined(); + }); +}); +``` + +**Best Practices:** +- Use `describe` blocks to group related tests +- Use `it` for individual test cases +- Use `expect` for assertions +- Tests run with `npm test` (runs `vitest run`) + +āŒ **Do not write unit tests for:** +- SQL files under `config/queries/` - little value in testing static SQL +- Types associated with queries - these are just schema definitions + +## Smoke Test (Playwright) + +The template includes a smoke test at `tests/smoke.spec.ts` that verifies the app loads correctly. + +**What the smoke test does:** +- Opens the app +- Waits for data to load (SQL query results) +- Verifies key UI elements are visible +- Captures screenshots and console logs to `.smoke-test/` directory +- Always captures artifacts, even on test failure + +**When customizing the app**, update `tests/smoke.spec.ts` to match your UI: +- Change heading selector to match your app title (replace 'Minimal Databricks App') +- Update data assertions to match your query results (replace 'hello world' check) +- Keep the test simple - just verify app loads and displays data +- The default test expects specific template content; update these expectations after customization + +**Keep smoke tests simple:** +- Only verify that the app loads and displays initial data +- Wait for key elements to appear (page title, main content) +- Capture artifacts for debugging +- Run quickly (< 5 seconds) + +**For extended E2E tests:** +- Create separate test files in `tests/` directory (e.g., `tests/user-flow.spec.ts`) +- Use `npm run test:e2e` to run all Playwright tests +- Keep complex user flows, interactions, and edge cases out of the smoke test diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md b/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md new file mode 100644 index 0000000000..acfb68c1b6 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md @@ -0,0 +1,95 @@ +# tRPC for Custom Endpoints + +**CRITICAL**: Do NOT use tRPC for SQL queries or data retrieval. Use `config/queries/` + `useAnalyticsQuery` instead. + +Use tRPC ONLY for: + +- **Mutations**: Creating, updating, or deleting data (INSERT, UPDATE, DELETE) +- **External APIs**: Calling Databricks APIs (serving endpoints, jobs, MLflow, etc.) +- **Complex business logic**: Multi-step operations that cannot be expressed in SQL +- **File operations**: File uploads, processing, transformations +- **Custom computations**: Operations requiring TypeScript/Node.js logic + +## Server-side Pattern + +```typescript +// server/trpc.ts +import { initTRPC } from '@trpc/server'; +import { getRequestContext } from '@databricks/appkit'; +import { z } from 'zod'; + +const t = initTRPC.create({ transformer: superjson }); +const publicProcedure = t.procedure; + +export const appRouter = t.router({ + // Example: Query a serving endpoint + queryModel: publicProcedure.input(z.object({ prompt: z.string() })).query(async ({ input: { prompt } }) => { + const { serviceDatabricksClient: client } = getRequestContext(); + const response = await client.servingEndpoints.query({ + name: 'your-endpoint-name', + messages: [{ role: 'user', content: prompt }], + }); + return response; + }), + + // Example: Mutation + createRecord: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ input }) => { + // Custom logic here + return { success: true, id: 123 }; + }), +}); +``` + +## Client-side Pattern + +```typescript +// client/src/components/MyComponent.tsx +import { trpc } from '@/lib/trpc'; +import { useState, useEffect } from 'react'; + +function MyComponent() { + const [result, setResult] = useState(null); + + useEffect(() => { + trpc.queryModel + .query({ prompt: "Hello" }) + .then(setResult) + .catch(console.error); + }, []); + + const handleCreate = async () => { + await trpc.createRecord.mutate({ name: "test" }); + }; + + return
{/* component JSX */}
; +} +``` + +## Decision Tree for Data Operations + +1. **Need to display data from SQL?** + - **Chart or Table?** → Use visualization components (`BarChart`, `LineChart`, `DataTable`, etc.) + - **Custom display (KPIs, cards, lists)?** → Use `useAnalyticsQuery` hook + - **Never** use tRPC for SQL SELECT statements + +2. **Need to call a Databricks API?** → Use tRPC + - Serving endpoints (model inference) + - MLflow operations + - Jobs API + - Workspace API + +3. **Need to modify data?** → Use tRPC mutations + - INSERT, UPDATE, DELETE operations + - Multi-step transactions + - Business logic with side effects + +4. **Need non-SQL custom logic?** → Use tRPC + - File processing + - External API calls + - Complex computations in TypeScript + +**Summary:** +- āœ… SQL queries → Visualization components or `useAnalyticsQuery` +- āœ… Databricks APIs → tRPC +- āœ… Data mutations → tRPC +- āŒ SQL queries → tRPC (NEVER do this) diff --git a/experimental/apps-mcp/templates/appkit/generic/eslint.config.js b/experimental/apps-mcp/templates/appkit/generic/eslint.config.js new file mode 100644 index 0000000000..5ac5ece83f --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/eslint.config.js @@ -0,0 +1,91 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import reactRefreshPlugin from 'eslint-plugin-react-refresh'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + // Global ignores + { + ignores: [ + '**/dist/**', + '**/build/**', + '**/node_modules/**', + '**/.next/**', + '**/coverage/**', + 'client/dist/**', + '**.databricks/**', + 'tests/**', + '**/.smoke-test/**', + ], + }, + + // Base JavaScript config + js.configs.recommended, + + // TypeScript config for all TS files + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + + // React config for client-side files + { + files: ['client/**/*.{ts,tsx}', '**/*.tsx'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + 'react-refresh': reactRefreshPlugin, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + ...reactPlugin.configs.recommended.rules, + ...reactPlugin.configs['jsx-runtime'].rules, + ...reactHooksPlugin.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'react/prop-types': 'off', // Using TypeScript for prop validation + 'react/no-array-index-key': 'warn', + }, + }, + + // Node.js specific config for server files + { + files: ['server/**/*.ts', '*.config.{js,ts}'], + rules: { + '@typescript-eslint/no-var-requires': 'off', + }, + }, + + // Disable type-checking for JS config files and standalone config files + { + files: ['**/*.js', '*.config.ts', '**/*.config.ts'], + ...tseslint.configs.disableTypeChecked, + }, + + // Prettier config (must be last to override other formatting rules) + prettier, + + // Custom rules + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + }, + } +); diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml new file mode 100644 index 0000000000..9228a9dd6b --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml @@ -0,0 +1,2 @@ + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: warehouse diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml new file mode 100644 index 0000000000..b3a631c08f --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml @@ -0,0 +1,4 @@ + - name: 'warehouse' + sql_warehouse: + id: ${var.warehouse_id} + permission: 'CAN_USE' diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml new file mode 100644 index 0000000000..ac4fbf1503 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml @@ -0,0 +1,2 @@ + warehouse_id: + description: The ID of the warehouse to use diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml new file mode 100644 index 0000000000..7d17f13ce1 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml @@ -0,0 +1 @@ +DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml new file mode 100644 index 0000000000..1ae1aa74e2 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml @@ -0,0 +1 @@ +DATABRICKS_WAREHOUSE_ID= diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml new file mode 100644 index 0000000000..0de7b63b32 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml @@ -0,0 +1 @@ + warehouse_id: {{.sql_warehouse_id}} diff --git a/experimental/apps-mcp/templates/appkit/generic/package.json b/experimental/apps-mcp/templates/appkit/generic/package.json new file mode 100644 index 0000000000..f3f9d4f0f5 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/package.json @@ -0,0 +1,79 @@ +{ + "name": "{{.project_name}}", + "version": "1.0.0", + "main": "build/index.js", + "type": "module", + "scripts": { + "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server/server.js", + "dev": "NODE_ENV=development tsx watch --tsconfig ./tsconfig.server.json --env-file-if-exists=./.env ./server/server.ts", + "build:client": "tsc -b tsconfig.client.json && vite build --config client/vite.config.ts", + "build:server": "tsc -b tsconfig.server.json", + "build": "npm run build:server && npm run build:client", + "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:ast-grep": "appkit-lint", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "test": "vitest run && npm run test:smoke", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", + "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", + "typegen": "appkit-generate-types", + "setup": "appkit-setup --write" + }, + "keywords": [], + "author": "", + "license": "Unlicensed", + "description": "{{.app_description}}", + "dependencies": { + "@databricks/appkit": "0.1.4", + "@databricks/appkit-ui": "0.1.4", + "@databricks/sdk-experimental": "^0.14.2", + "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^0.546.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-resizable-panels": "^3.0.6", + "superjson": "^2.2.5", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.4.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.13" + }, + "devDependencies": { + "@ast-grep/napi": "^0.37.0", + "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", + "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/vite": "^4.1.17", + "@types/express": "^5.0.5", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@typescript-eslint/eslint-plugin": "^8.48.0", + "@typescript-eslint/parser": "^8.48.0", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.21", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "prettier": "^3.6.2", + "sharp": "^0.34.5", + "tailwindcss": "^4.0.14", + "tsx": "^4.20.6", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "npm:rolldown-vite@7.1.14", + "vitest": "^4.0.14" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } +} diff --git a/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts b/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts new file mode 100644 index 0000000000..c4cad7a53d --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: `http://localhost:${process.env.PORT || 8000}`, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: `http://localhost:${process.env.PORT || 8000}`, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/experimental/apps-mcp/templates/appkit/generic/server/server.ts b/experimental/apps-mcp/templates/appkit/generic/server/server.ts new file mode 100644 index 0000000000..da04192770 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/server/server.ts @@ -0,0 +1,8 @@ +import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; + +createApp({ + plugins: [ + server(), + {{.plugin_usage}}, + ], +}).catch(console.error); diff --git a/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts b/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts new file mode 100644 index 0000000000..d712c69588 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +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 + 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 }); + + // Verify console logs were captured + expect(consoleLogs.length).toBeGreaterThan(0); + expect(consoleErrors.length).toBe(0); + expect(pageErrors.length).toBe(0); +}); + +test.beforeEach(async ({ page }) => { + consoleLogs = []; + consoleErrors = []; + pageErrors = []; + failedRequests = []; + + // Create temp directory for test artifacts + testArtifactsDir = join(process.cwd(), '.smoke-test'); + mkdirSync(testArtifactsDir, { recursive: true }); + + // Capture console logs and errors (including React errors) + page.on('console', (msg) => { + const type = msg.type(); + const text = msg.text(); + + // Skip empty lines and formatting placeholders + if (!text.trim() || /^%[osd]$/.test(text.trim())) { + return; + } + + // Get stack trace for errors if available + const location = msg.location(); + const locationStr = location.url ? ` at ${location.url}:${location.lineNumber}:${location.columnNumber}` : ''; + + consoleLogs.push(`[${type}] ${text}${locationStr}`); + + // Separately track error messages (React errors appear here) + if (type === 'error') { + consoleErrors.push(`${text}${locationStr}`); + } + }); + + // Capture page errors with full stack trace + page.on('pageerror', (error) => { + const errorDetails = `Page error: ${error.message}\nStack: ${error.stack || 'No stack trace available'}`; + pageErrors.push(errorDetails); + // Also log to console for immediate visibility + console.error('Page error detected:', errorDetails); + }); + + // Capture failed requests + page.on('requestfailed', (request) => { + failedRequests.push(`Failed request: ${request.url()} - ${request.failure()?.errorText}`); + }); +}); + +test.afterEach(async ({ page }, testInfo) => { + const testName = testInfo.title.replace(/ /g, '-').toLowerCase(); + // Always capture artifacts, even if test fails + const screenshotPath = join(testArtifactsDir, `${testName}-app-screenshot.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + + const logsPath = join(testArtifactsDir, `${testName}-console-logs.txt`); + const allLogs = [ + '=== Console Logs ===', + ...consoleLogs, + '\n=== Console Errors (React errors) ===', + ...consoleErrors, + '\n=== Page Errors ===', + ...pageErrors, + '\n=== Failed Requests ===', + ...failedRequests, + ]; + writeFileSync(logsPath, allLogs.join('\n'), 'utf-8'); + + console.log(`Screenshot saved to: ${screenshotPath}`); + console.log(`Console logs saved to: ${logsPath}`); + if (consoleErrors.length > 0) { + console.log('Console errors detected:', consoleErrors); + } + if (pageErrors.length > 0) { + console.log('Page errors detected:', pageErrors); + } + if (failedRequests.length > 0) { + console.log('Failed requests detected:', failedRequests); + } + + await page.close(); +}); diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json new file mode 100644 index 0000000000..8732ba46b8 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.shared.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.client.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "erasableSyntaxOnly": true, + "noUncheckedSideEffectImports": true, + + /* Paths */ + "baseUrl": "./client", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["client/src"] +} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.json new file mode 100644 index 0000000000..a51fbad9ae --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.client.json" }, { "path": "./tsconfig.server.json" }] +} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json new file mode 100644 index 0000000000..8cdada22cb --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.shared.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + "target": "ES2020", + "lib": ["ES2020"], + + /* Emit */ + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["server/**/*", "shared/**/*", "config/**/*"], + "exclude": ["node_modules", "dist", "client"] +} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json new file mode 100644 index 0000000000..3187705b41 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + + /* Modules */ + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + + /* Emit */ + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts b/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts new file mode 100644 index 0000000000..98134fd5a8 --- /dev/null +++ b/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + exclude: ['**/node_modules/**', '**/dist/**', '**/*.spec.ts', '**/.smoke-test/**', '**/.databricks/**'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './client/src'), + }, + }, +}); diff --git a/experimental/dev/cmd/app/app.go b/experimental/dev/cmd/app/app.go new file mode 100644 index 0000000000..f20a5ec106 --- /dev/null +++ b/experimental/dev/cmd/app/app.go @@ -0,0 +1,24 @@ +package app + +import ( + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "app", + Short: "Manage Databricks applications", + Long: `Manage Databricks applications. + +Provides a streamlined interface for creating, managing, and monitoring +full-stack Databricks applications built with TypeScript, React, and +Tailwind CSS.`, + } + + cmd.AddCommand(newInitCmd()) + cmd.AddCommand(newImportCmd()) + cmd.AddCommand(newDeployCmd()) + cmd.AddCommand(newDevRemoteCmd()) + + return cmd +} diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go new file mode 100644 index 0000000000..85d9ffa814 --- /dev/null +++ b/experimental/dev/cmd/app/deploy.go @@ -0,0 +1,228 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "sync" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/resources" + "github.com/databricks/cli/bundle/run" + "github.com/databricks/cli/cmd/bundle/utils" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/spf13/cobra" +) + +func newDeployCmd() *cobra.Command { + var ( + force bool + skipBuild bool + ) + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Build, deploy the AppKit application and run it", + Long: `Build, deploy the AppKit application and run it. + +This command runs a deployment pipeline: +1. Builds the frontend (npm run build) +2. Deploys the bundle to the workspace +3. Runs the app + +Examples: + # Deploy to default target + databricks experimental dev app deploy + + # Deploy to a specific target + databricks experimental dev app deploy --target prod + + # Skip frontend build (if already built) + databricks experimental dev app deploy --skip-build + + # Force deploy (override git branch validation) + databricks experimental dev app deploy --force + + # Set bundle variables + databricks experimental dev app deploy --var="warehouse_id=abc123"`, + Args: root.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeploy(cmd, force, skipBuild) + }, + } + + cmd.Flags().StringP("target", "t", "", "Deployment target (e.g., dev, prod)") + cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") + cmd.Flags().BoolVar(&skipBuild, "skip-build", false, "Skip npm build step") + cmd.Flags().StringSlice("var", []string{}, `Set values for variables defined in bundle config. Example: --var="key=value"`) + + return cmd +} + +func runDeploy(cmd *cobra.Command, force, skipBuild bool) error { + ctx := cmd.Context() + + // Check for bundle configuration + if _, err := os.Stat("databricks.yml"); os.IsNotExist(err) { + return errors.New("no databricks.yml found; run this command from a bundle directory") + } + + // Step 1: Build frontend (unless skipped) + if !skipBuild { + if err := runNpmTypegen(ctx); err != nil { + return err + } + if err := runNpmBuild(ctx); err != nil { + return err + } + } + + // Step 2: Deploy bundle + cmdio.LogString(ctx, "Deploying bundle...") + b, err := utils.ProcessBundle(cmd, utils.ProcessOptions{ + InitFunc: func(b *bundle.Bundle) { + b.Config.Bundle.Force = force + }, + AlwaysPull: true, + FastValidate: true, + Build: true, + Deploy: true, + }) + if err != nil { + return fmt.Errorf("deploy failed: %w", err) + } + log.Infof(ctx, "Deploy completed") + + // Step 3: Detect and run app + appKey, err := detectApp(b) + if err != nil { + return err + } + + log.Infof(ctx, "Running app: %s", appKey) + if err := runApp(ctx, b, appKey); err != nil { + cmdio.LogString(ctx, "āœ” Deployment succeeded, but failed to start app") + return fmt.Errorf("failed to run app: %w", err) + } + + cmdio.LogString(ctx, "āœ” Deployment complete!") + return nil +} + +// syncBuffer is a thread-safe buffer for capturing command output. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (n int, err error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// runNpmTypegen runs npm run typegen in the current directory. +func runNpmTypegen(ctx context.Context) error { + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm not found: please install Node.js") + } + + var output syncBuffer + + err := RunWithSpinnerCtx(ctx, "Generating types...", func() error { + cmd := exec.CommandContext(ctx, "npm", "run", "typegen") + cmd.Stdout = &output + cmd.Stderr = &output + return cmd.Run() + }) + if err != nil { + out := output.String() + if out != "" { + return fmt.Errorf("typegen failed:\n%s", out) + } + return fmt.Errorf("typegen failed: %w", err) + } + return nil +} + +// runNpmBuild runs npm run build in the current directory. +func runNpmBuild(ctx context.Context) error { + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm not found: please install Node.js") + } + + var output syncBuffer + + err := RunWithSpinnerCtx(ctx, "Building frontend...", func() error { + cmd := exec.CommandContext(ctx, "npm", "run", "build") + cmd.Stdout = &output + cmd.Stderr = &output + return cmd.Run() + }) + if err != nil { + out := output.String() + if out != "" { + return fmt.Errorf("build failed:\n%s", out) + } + return fmt.Errorf("build failed: %w", err) + } + return nil +} + +// detectApp finds the single app in the bundle configuration. +func detectApp(b *bundle.Bundle) (string, error) { + apps := b.Config.Resources.Apps + + if len(apps) == 0 { + return "", errors.New("no apps found in bundle configuration") + } + + if len(apps) > 1 { + return "", errors.New("multiple apps found in bundle, cannot auto-detect") + } + + for key := range apps { + return key, nil + } + + return "", errors.New("unexpected error detecting app") +} + +// runApp runs the specified app using the runner interface. +func runApp(ctx context.Context, b *bundle.Bundle, appKey string) error { + ref, err := resources.Lookup(b, appKey, run.IsRunnable) + if err != nil { + return fmt.Errorf("failed to lookup app: %w", err) + } + + runner, err := run.ToRunner(b, ref) + if err != nil { + return fmt.Errorf("failed to create runner: %w", err) + } + + output, err := runner.Run(ctx, &run.Options{}) + if err != nil { + return fmt.Errorf("failed to run app: %w", err) + } + + if output != nil { + resultString, err := output.String() + if err != nil { + return err + } + log.Infof(ctx, "App output: %s", resultString) + } + + return nil +} diff --git a/experimental/dev/cmd/app/dev_remote.go b/experimental/dev/cmd/app/dev_remote.go new file mode 100644 index 0000000000..a819ff870e --- /dev/null +++ b/experimental/dev/cmd/app/dev_remote.go @@ -0,0 +1,243 @@ +package app + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" +) + +//go:embed vite-server.js +var viteServerScript []byte + +const ( + vitePort = 5173 + viteReadyCheckInterval = 100 * time.Millisecond + viteReadyMaxAttempts = 50 +) + +func isViteReady(port int) bool { + conn, err := net.DialTimeout("tcp", "localhost:"+strconv.Itoa(port), viteReadyCheckInterval) + if err != nil { + return false + } + conn.Close() + return true +} + +// detectAppNameFromBundle tries to extract the app name from a databricks.yml bundle config. +// Returns the app name if found, or empty string if no bundle or no apps found. +func detectAppNameFromBundle() string { + const bundleFile = "databricks.yml" + + // Check if databricks.yml exists + if _, err := os.Stat(bundleFile); os.IsNotExist(err) { + return "" + } + + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return "" + } + + // Load the bundle configuration directly + rootConfig, diags := config.Load(filepath.Join(cwd, bundleFile)) + if diags.HasError() { + return "" + } + + // Check for apps in the bundle + apps := rootConfig.Resources.Apps + if len(apps) == 0 { + return "" + } + + // If there's exactly one app, return its name + if len(apps) == 1 { + for _, app := range apps { + return app.Name + } + } + + // Multiple apps - can't auto-detect + return "" +} + +func startViteDevServer(ctx context.Context, appURL string, port int) (*exec.Cmd, chan error, error) { + // Pass script through stdin, and pass arguments in order + viteCmd := exec.Command("node", "-", appURL, strconv.Itoa(port)) + viteCmd.Stdin = bytes.NewReader(viteServerScript) + viteCmd.Stdout = os.Stdout + viteCmd.Stderr = os.Stderr + + err := viteCmd.Start() + if err != nil { + return nil, nil, fmt.Errorf("failed to start Vite server: %w", err) + } + + cmdio.LogString(ctx, fmt.Sprintf("šŸš€ Starting Vite development server on port %d...", port)) + + viteErr := make(chan error, 1) + go func() { + if err := viteCmd.Wait(); err != nil { + viteErr <- fmt.Errorf("vite server exited with error: %w", err) + } else { + viteErr <- errors.New("vite server exited unexpectedly") + } + }() + + for range viteReadyMaxAttempts { + select { + case err := <-viteErr: + return nil, nil, err + default: + if isViteReady(port) { + return viteCmd, viteErr, nil + } + time.Sleep(viteReadyCheckInterval) + } + } + + _ = viteCmd.Process.Kill() + return nil, nil, errors.New("timeout waiting for Vite server to be ready") +} + +func newDevRemoteCmd() *cobra.Command { + var ( + appName string + clientPath string + port int + ) + + cmd := &cobra.Command{ + Use: "dev-remote", + Short: "Run AppKit app locally with WebSocket bridge to remote server", + Long: `Run AppKit app locally with WebSocket bridge to remote server. + +Starts a local Vite development server and establishes a WebSocket bridge +to the remote Databricks app for development with hot module replacement. + +Examples: + # Interactive mode - select app from picker + databricks experimental dev app dev-remote + + # Start development server for a specific app + databricks experimental dev app dev-remote --name my-app + + # Use a custom client path + databricks experimental dev app dev-remote --name my-app --client-path ./frontend + + # Use a custom port + databricks experimental dev app dev-remote --name my-app --port 3000`, + Args: root.NoArgs, + PreRunE: root.MustWorkspaceClient, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Validate client path early (before any network calls) + if _, err := os.Stat(clientPath); os.IsNotExist(err) { + return fmt.Errorf("client directory not found: %s", clientPath) + } + + // Check if port is already in use + if isViteReady(port) { + return fmt.Errorf("port %d is already in use; try --port ", port) + } + + w := cmdctx.WorkspaceClient(ctx) + + // Resolve app name with priority: flag > bundle config > prompt + if appName == "" { + // Try to detect from bundle config + appName = detectAppNameFromBundle() + if appName != "" { + cmdio.LogString(ctx, fmt.Sprintf("Using app '%s' from bundle configuration", appName)) + } + } + + if appName == "" { + // Fall back to interactive prompt + selected, err := PromptForAppSelection(ctx, "Select an app to connect to") + if err != nil { + return err + } + appName = selected + } + + bridge := NewViteBridge(ctx, w, appName, port) + + // Validate app exists and get domain before starting Vite + var appDomain *url.URL + err := RunWithSpinnerCtx(ctx, "Connecting to app...", func() error { + var domainErr error + appDomain, domainErr = bridge.GetAppDomain() + return domainErr + }) + if err != nil { + return fmt.Errorf("failed to get app domain: %w", err) + } + + viteCmd, viteErr, err := startViteDevServer(ctx, appDomain.String(), port) + if err != nil { + return err + } + + done := make(chan error, 1) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + go func() { + done <- bridge.Start() + }() + + select { + case err := <-viteErr: + bridge.Stop() + <-done + return err + case err := <-done: + cmdio.LogString(ctx, "Bridge stopped") + if viteCmd.Process != nil { + _ = viteCmd.Process.Signal(os.Interrupt) + <-viteErr + } + return err + case <-sigChan: + cmdio.LogString(ctx, "\nšŸ›‘ Shutting down...") + bridge.Stop() + <-done + if viteCmd.Process != nil { + if err := viteCmd.Process.Signal(os.Interrupt); err != nil { + cmdio.LogString(ctx, fmt.Sprintf("Failed to interrupt Vite: %v", err)) + _ = viteCmd.Process.Kill() + } + <-viteErr + } + return nil + } + }, + } + + cmd.Flags().StringVar(&appName, "name", "", "Name of the app to connect to (prompts if not provided)") + cmd.Flags().StringVar(&clientPath, "client-path", "./client", "Path to the Vite client directory") + cmd.Flags().IntVar(&port, "port", vitePort, "Port to run the Vite server on") + + return cmd +} diff --git a/experimental/dev/cmd/app/dev_remote_test.go b/experimental/dev/cmd/app/dev_remote_test.go new file mode 100644 index 0000000000..cb0c1ec72d --- /dev/null +++ b/experimental/dev/cmd/app/dev_remote_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "context" + "net" + "os" + "testing" + "time" + + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsViteReady(t *testing.T) { + t.Run("vite not running", func(t *testing.T) { + // Assuming nothing is running on port 5173 + ready := isViteReady(5173) + assert.False(t, ready) + }) + + t.Run("vite is running", func(t *testing.T) { + // Start a mock server on the Vite port + listener, err := net.Listen("tcp", "localhost:5173") + require.NoError(t, err) + defer listener.Close() + + // Accept connections in the background + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + // Give the listener a moment to start + time.Sleep(50 * time.Millisecond) + + ready := isViteReady(5173) + assert.True(t, ready) + }) +} + +func TestViteServerScriptContent(t *testing.T) { + // Verify the embedded script is not empty + assert.NotEmpty(t, viteServerScript) + + // Verify it's a JavaScript file with expected content + assert.Contains(t, string(viteServerScript), "startViteServer") +} + +func TestStartViteDevServerNoNode(t *testing.T) { + // Skip this test if node is not available or in CI environments + if os.Getenv("CI") != "" { + t.Skip("Skipping node-dependent test in CI") + } + + ctx := context.Background() + ctx = cmdio.MockDiscard(ctx) + + // Create a temporary directory to act as project root + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldWd) }() + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create a client directory + err = os.Mkdir("client", 0o755) + require.NoError(t, err) + + // Try to start Vite server with invalid app URL (will fail fast) + // This test mainly verifies the function signature and error handling + _, _, err = startViteDevServer(ctx, "", 5173) + assert.Error(t, err) +} + +func TestViteServerScriptEmbedded(t *testing.T) { + assert.NotEmpty(t, viteServerScript) + + scriptContent := string(viteServerScript) + assert.Contains(t, scriptContent, "startViteServer") + assert.Contains(t, scriptContent, "createServer") + assert.Contains(t, scriptContent, "queriesHMRPlugin") +} diff --git a/experimental/dev/cmd/app/features.go b/experimental/dev/cmd/app/features.go new file mode 100644 index 0000000000..6e03b19de7 --- /dev/null +++ b/experimental/dev/cmd/app/features.go @@ -0,0 +1,210 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// FeatureDependency defines a prompt/input required by a feature. +type FeatureDependency struct { + ID string // e.g., "sql_warehouse_id" + FlagName string // CLI flag name, e.g., "warehouse-id" (maps to --warehouse-id) + Title string // e.g., "SQL Warehouse ID" + Description string // e.g., "Required for executing SQL queries" + Placeholder string + Required bool +} + +// FeatureResourceFiles defines paths to YAML fragment files for a feature's resources. +// Paths are relative to the template's features directory (e.g., "analytics/bundle_variables.yml"). +type FeatureResourceFiles struct { + BundleVariables string // Variables section for databricks.yml + BundleResources string // Resources section for databricks.yml (app resources) + TargetVariables string // Dev target variables section for databricks.yml + AppEnv string // Environment variables for app.yaml + DotEnv string // Environment variables for .env (development) + DotEnvExample string // Environment variables for .env.example +} + +// Feature represents an optional feature that can be added to an AppKit project. +type Feature struct { + ID string + Name string + Description string + PluginImport string + PluginUsage string + Dependencies []FeatureDependency + ResourceFiles FeatureResourceFiles +} + +// AvailableFeatures lists all features that can be selected when creating a project. +var AvailableFeatures = []Feature{ + { + ID: "analytics", + Name: "Analytics", + Description: "SQL analytics with charts and dashboards", + PluginImport: "analytics", + PluginUsage: "analytics()", + Dependencies: []FeatureDependency{ + { + ID: "sql_warehouse_id", + FlagName: "warehouse-id", + Title: "SQL Warehouse ID", + Description: "required for SQL queries", + Required: true, + }, + }, + ResourceFiles: FeatureResourceFiles{ + BundleVariables: "analytics/bundle_variables.yml", + BundleResources: "analytics/bundle_resources.yml", + TargetVariables: "analytics/target_variables.yml", + AppEnv: "analytics/app_env.yml", + DotEnv: "analytics/dotenv.yml", + DotEnvExample: "analytics/dotenv_example.yml", + }, + }, +} + +var featureByID = func() map[string]Feature { + m := make(map[string]Feature, len(AvailableFeatures)) + for _, f := range AvailableFeatures { + m[f.ID] = f + } + return m +}() + +// ValidateFeatureIDs checks that all provided feature IDs are valid. +// Returns an error if any feature ID is unknown. +func ValidateFeatureIDs(featureIDs []string) error { + for _, id := range featureIDs { + if _, ok := featureByID[id]; !ok { + return fmt.Errorf("unknown feature: %q; available: %s", id, strings.Join(GetFeatureIDs(), ", ")) + } + } + return nil +} + +// ValidateFeatureDependencies checks that all required dependencies for the given features +// are provided in the flagValues map. Returns an error listing missing required flags. +func ValidateFeatureDependencies(featureIDs []string, flagValues map[string]string) error { + deps := CollectDependencies(featureIDs) + var missing []string + + for _, dep := range deps { + if !dep.Required { + continue + } + value, ok := flagValues[dep.FlagName] + if !ok || value == "" { + missing = append(missing, "--"+dep.FlagName) + } + } + + if len(missing) > 0 { + return fmt.Errorf("missing required flags for selected features: %s", strings.Join(missing, ", ")) + } + return nil +} + +// GetFeatureIDs returns a list of all available feature IDs for help text. +func GetFeatureIDs() []string { + ids := make([]string, len(AvailableFeatures)) + for i, f := range AvailableFeatures { + ids[i] = f.ID + } + return ids +} + +// BuildPluginStrings builds the plugin import and usage strings from selected feature IDs. +// Returns comma-separated imports and newline-separated usages. +func BuildPluginStrings(featureIDs []string) (pluginImport, pluginUsage string) { + if len(featureIDs) == 0 { + return "", "" + } + + var imports []string + var usages []string + + for _, id := range featureIDs { + feature, ok := featureByID[id] + if !ok || feature.PluginImport == "" { + continue + } + imports = append(imports, feature.PluginImport) + usages = append(usages, feature.PluginUsage) + } + + if len(imports) == 0 { + return "", "" + } + + // Join imports with comma (e.g., "analytics, trpc") + pluginImport = strings.Join(imports, ", ") + + // Join usages with newline and proper indentation + pluginUsage = strings.Join(usages, ",\n ") + + return pluginImport, pluginUsage +} + +// ApplyFeatures applies any post-copy modifications for selected features. +// This removes feature-specific directories if the feature is not selected. +func ApplyFeatures(projectDir string, featureIDs []string) error { + selectedSet := make(map[string]bool) + for _, id := range featureIDs { + selectedSet[id] = true + } + + // Remove analytics-specific files if analytics is not selected + if !selectedSet["analytics"] { + queriesDir := filepath.Join(projectDir, "config", "queries") + if err := os.RemoveAll(queriesDir); err != nil && !os.IsNotExist(err) { + return err + } + } + + return nil +} + +// CollectDependencies returns all unique dependencies required by the selected features. +func CollectDependencies(featureIDs []string) []FeatureDependency { + seen := make(map[string]bool) + var deps []FeatureDependency + + for _, id := range featureIDs { + feature, ok := featureByID[id] + if !ok { + continue + } + for _, dep := range feature.Dependencies { + if !seen[dep.ID] { + seen[dep.ID] = true + deps = append(deps, dep) + } + } + } + + return deps +} + +// CollectResourceFiles returns all resource file paths for the selected features. +func CollectResourceFiles(featureIDs []string) []FeatureResourceFiles { + var resources []FeatureResourceFiles + for _, id := range featureIDs { + feature, ok := featureByID[id] + if !ok { + continue + } + // Only include if at least one resource file is defined + rf := feature.ResourceFiles + if rf.BundleVariables != "" || rf.BundleResources != "" || + rf.TargetVariables != "" || rf.AppEnv != "" || + rf.DotEnv != "" || rf.DotEnvExample != "" { + resources = append(resources, rf) + } + } + + return resources +} diff --git a/experimental/dev/cmd/app/features_test.go b/experimental/dev/cmd/app/features_test.go new file mode 100644 index 0000000000..18d34fe9f7 --- /dev/null +++ b/experimental/dev/cmd/app/features_test.go @@ -0,0 +1,250 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateFeatureIDs(t *testing.T) { + tests := []struct { + name string + featureIDs []string + expectError bool + errorMsg string + }{ + { + name: "valid feature - analytics", + featureIDs: []string{"analytics"}, + expectError: false, + }, + { + name: "empty feature list", + featureIDs: []string{}, + expectError: false, + }, + { + name: "nil feature list", + featureIDs: nil, + expectError: false, + }, + { + name: "unknown feature", + featureIDs: []string{"unknown-feature"}, + expectError: true, + errorMsg: "unknown feature", + }, + { + name: "mix of valid and invalid", + featureIDs: []string{"analytics", "invalid"}, + expectError: true, + errorMsg: "unknown feature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFeatureIDs(tt.featureIDs) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateFeatureDependencies(t *testing.T) { + tests := []struct { + name string + featureIDs []string + flagValues map[string]string + expectError bool + errorMsg string + }{ + { + name: "analytics with warehouse provided", + featureIDs: []string{"analytics"}, + flagValues: map[string]string{"warehouse-id": "abc123"}, + expectError: false, + }, + { + name: "analytics without warehouse", + featureIDs: []string{"analytics"}, + flagValues: map[string]string{}, + expectError: true, + errorMsg: "--warehouse-id", + }, + { + name: "analytics with empty warehouse", + featureIDs: []string{"analytics"}, + flagValues: map[string]string{"warehouse-id": ""}, + expectError: true, + errorMsg: "--warehouse-id", + }, + { + name: "no features - no dependencies needed", + featureIDs: []string{}, + flagValues: map[string]string{}, + expectError: false, + }, + { + name: "unknown feature - gracefully ignored", + featureIDs: []string{"unknown"}, + flagValues: map[string]string{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFeatureDependencies(tt.featureIDs, tt.flagValues) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetFeatureIDs(t *testing.T) { + ids := GetFeatureIDs() + + assert.NotEmpty(t, ids) + assert.Contains(t, ids, "analytics") +} + +func TestBuildPluginStrings(t *testing.T) { + tests := []struct { + name string + featureIDs []string + expectedImport string + expectedUsage string + }{ + { + name: "no features", + featureIDs: []string{}, + expectedImport: "", + expectedUsage: "", + }, + { + name: "nil features", + featureIDs: nil, + expectedImport: "", + expectedUsage: "", + }, + { + name: "analytics feature", + featureIDs: []string{"analytics"}, + expectedImport: "analytics", + expectedUsage: "analytics()", + }, + { + name: "unknown feature - ignored", + featureIDs: []string{"unknown"}, + expectedImport: "", + expectedUsage: "", + }, + { + name: "mix of known and unknown", + featureIDs: []string{"analytics", "unknown"}, + expectedImport: "analytics", + expectedUsage: "analytics()", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + importStr, usageStr := BuildPluginStrings(tt.featureIDs) + assert.Equal(t, tt.expectedImport, importStr) + assert.Equal(t, tt.expectedUsage, usageStr) + }) + } +} + +func TestCollectDependencies(t *testing.T) { + tests := []struct { + name string + featureIDs []string + expectedDeps int + expectedIDs []string + }{ + { + name: "no features", + featureIDs: []string{}, + expectedDeps: 0, + expectedIDs: nil, + }, + { + name: "analytics feature", + featureIDs: []string{"analytics"}, + expectedDeps: 1, + expectedIDs: []string{"sql_warehouse_id"}, + }, + { + name: "unknown feature", + featureIDs: []string{"unknown"}, + expectedDeps: 0, + expectedIDs: nil, + }, + { + name: "duplicate features - deduped deps", + featureIDs: []string{"analytics", "analytics"}, + expectedDeps: 1, + expectedIDs: []string{"sql_warehouse_id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := CollectDependencies(tt.featureIDs) + assert.Len(t, deps, tt.expectedDeps) + + if tt.expectedIDs != nil { + for i, expectedID := range tt.expectedIDs { + assert.Equal(t, expectedID, deps[i].ID) + } + } + }) + } +} + +func TestCollectResourceFiles(t *testing.T) { + tests := []struct { + name string + featureIDs []string + expectedResources int + }{ + { + name: "no features", + featureIDs: []string{}, + expectedResources: 0, + }, + { + name: "analytics feature", + featureIDs: []string{"analytics"}, + expectedResources: 1, + }, + { + name: "unknown feature", + featureIDs: []string{"unknown"}, + expectedResources: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resources := CollectResourceFiles(tt.featureIDs) + assert.Len(t, resources, tt.expectedResources) + + if tt.expectedResources > 0 && tt.featureIDs[0] == "analytics" { + assert.NotEmpty(t, resources[0].BundleVariables) + assert.NotEmpty(t, resources[0].BundleResources) + } + }) + } +} diff --git a/experimental/dev/cmd/app/import.go b/experimental/dev/cmd/app/import.go new file mode 100644 index 0000000000..627272f083 --- /dev/null +++ b/experimental/dev/cmd/app/import.go @@ -0,0 +1,265 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/databricks/databricks-sdk-go/service/workspace" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +func newImportCmd() *cobra.Command { + var ( + appName string + force bool + outputDir string + ) + + cmd := &cobra.Command{ + Use: "import", + Short: "Import app source code from Databricks workspace to local disk", + Long: `Import app source code from Databricks workspace to local disk. + +Downloads the source code of a deployed Databricks app to a local directory +named after the app. + +Examples: + # Interactive mode - select app from picker + databricks experimental dev app import + + # Import a specific app's source code + databricks experimental dev app import --name my-app + + # Import to a specific directory + databricks experimental dev app import --name my-app --output-dir ./projects + + # Force overwrite existing files + databricks experimental dev app import --name my-app --force`, + Args: root.NoArgs, + PreRunE: root.MustWorkspaceClient, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Prompt for app name if not provided + if appName == "" { + selected, err := PromptForAppSelection(ctx, "Select an app to import") + if err != nil { + return err + } + appName = selected + } + + return runImport(ctx, importOptions{ + appName: appName, + force: force, + outputDir: outputDir, + }) + }, + } + + cmd.Flags().StringVar(&appName, "name", "", "Name of the app to import (prompts if not provided)") + cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the imported app to") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files") + + return cmd +} + +type importOptions struct { + appName string + force bool + outputDir string +} + +func runImport(ctx context.Context, opts importOptions) error { + w := cmdctx.WorkspaceClient(ctx) + + // Step 1: Fetch the app + var app *apps.App + err := RunWithSpinnerCtx(ctx, fmt.Sprintf("Fetching app '%s'...", opts.appName), func() error { + var fetchErr error + app, fetchErr = w.Apps.Get(ctx, apps.GetAppRequest{Name: opts.appName}) + return fetchErr + }) + if err != nil { + return fmt.Errorf("failed to get app: %w", err) + } + + // Step 2: Check if the app has a source code path + if app.DefaultSourceCodePath == "" { + return errors.New("app has no source code path - it may not have been deployed yet") + } + + cmdio.LogString(ctx, fmt.Sprintf("Source code path: %s", app.DefaultSourceCodePath)) + + // Step 3: Create output directory + destDir := opts.appName + if opts.outputDir != "" { + destDir = filepath.Join(opts.outputDir, opts.appName) + } + if err := ensureOutputDir(destDir, opts.force); err != nil { + return err + } + + // Step 4: Download files with spinner + var fileCount int + err = RunWithSpinnerCtx(ctx, "Downloading files...", func() error { + var downloadErr error + fileCount, downloadErr = downloadDirectory(ctx, w, app.DefaultSourceCodePath, destDir, opts.force) + return downloadErr + }) + if err != nil { + return fmt.Errorf("failed to download files for app '%s': %w", opts.appName, err) + } + + // Get absolute path for display + absDestDir, err := filepath.Abs(destDir) + if err != nil { + absDestDir = destDir + } + + // Step 5: Run npm install if package.json exists + packageJSONPath := filepath.Join(destDir, "package.json") + if _, err := os.Stat(packageJSONPath); err == nil { + if err := runNpmInstallInDir(ctx, destDir); err != nil { + cmdio.LogString(ctx, fmt.Sprintf("⚠ npm install failed: %v", err)) + cmdio.LogString(ctx, " You can run 'npm install' manually in the project directory.") + } + } + + // Step 6: Detect and configure DABs + bundlePath := filepath.Join(destDir, "databricks.yml") + if _, err := os.Stat(bundlePath); err == nil { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Detected Databricks Asset Bundle configuration.") + cmdio.LogString(ctx, "Run 'databricks bundle validate' to verify the bundle is configured correctly.") + } + + // Show success message with next steps + PrintSuccess(opts.appName, absDestDir, fileCount, true) + + return nil +} + +// runNpmInstallInDir runs npm install in the specified directory. +func runNpmInstallInDir(ctx context.Context, dir string) error { + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm not found: please install Node.js") + } + + return RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + cmd := exec.CommandContext(ctx, "npm", "install") + cmd.Dir = dir + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() + }) +} + +// ensureOutputDir creates the output directory or checks if it's safe to use. +func ensureOutputDir(dir string, force bool) error { + info, err := os.Stat(dir) + if err == nil { + if !info.IsDir() { + return fmt.Errorf("%s exists but is not a directory", dir) + } + if !force { + return fmt.Errorf("directory %s already exists (use --force to overwrite)", dir) + } + } else if !os.IsNotExist(err) { + return err + } + + return os.MkdirAll(dir, 0o755) +} + +// downloadDirectory recursively downloads all files from a workspace path to a local directory. +func downloadDirectory(ctx context.Context, w *databricks.WorkspaceClient, remotePath, localDir string, force bool) (int, error) { + // List all files recursively + objects, err := w.Workspace.RecursiveList(ctx, remotePath) + if err != nil { + return 0, fmt.Errorf("failed to list workspace files: %w", err) + } + + // Filter out directories, keep only files + var files []workspace.ObjectInfo + for _, obj := range objects { + if obj.ObjectType != workspace.ObjectTypeDirectory { + files = append(files, obj) + } + } + + if len(files) == 0 { + return 0, errors.New("no files found in app source code path") + } + + // Download files in parallel + errs, errCtx := errgroup.WithContext(ctx) + errs.SetLimit(10) // Limit concurrent downloads + + for _, file := range files { + errs.Go(func() error { + return downloadFile(errCtx, w, file, remotePath, localDir, force) + }) + } + + if err := errs.Wait(); err != nil { + return 0, err + } + + return len(files), nil +} + +// downloadFile downloads a single file from the workspace to the local directory. +func downloadFile(ctx context.Context, w *databricks.WorkspaceClient, file workspace.ObjectInfo, remotePath, localDir string, force bool) error { + // Calculate relative path from the remote root + relPath := strings.TrimPrefix(file.Path, remotePath) + relPath = strings.TrimPrefix(relPath, "/") + + // Determine local file path + localPath := filepath.Join(localDir, relPath) + + // Check if file exists + if !force { + if _, err := os.Stat(localPath); err == nil { + return fmt.Errorf("file %s already exists (use --force to overwrite)", localPath) + } + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", localPath, err) + } + + // Download file content + reader, err := w.Workspace.Download(ctx, file.Path) + if err != nil { + return fmt.Errorf("failed to download %s: %w", file.Path, err) + } + defer reader.Close() + + // Create local file + localFile, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("failed to create %s: %w", localPath, err) + } + defer localFile.Close() + + // Copy content + if _, err := io.Copy(localFile, reader); err != nil { + return fmt.Errorf("failed to write %s: %w", localPath, err) + } + + return nil +} diff --git a/experimental/dev/cmd/app/init.go b/experimental/dev/cmd/app/init.go new file mode 100644 index 0000000000..2fcbcc2ce9 --- /dev/null +++ b/experimental/dev/cmd/app/init.go @@ -0,0 +1,837 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" +) + +const ( + templatePathEnvVar = "DATABRICKS_APPKIT_TEMPLATE_PATH" +) + +func newInitCmd() *cobra.Command { + var ( + templatePath string + branch string + name string + warehouseID string + description string + outputDir string + features []string + deploy bool + run string + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize a new AppKit application from a template", + Long: `Initialize a new AppKit application from a template. + +When run without arguments, an interactive prompt guides you through the setup. +When run with --name, runs in non-interactive mode (all required flags must be provided). + +Examples: + # Interactive mode (recommended) + databricks experimental dev app init + + # Non-interactive with flags + databricks experimental dev app init --name my-app + + # With analytics feature (requires --warehouse-id) + databricks experimental dev app init --name my-app --features=analytics --warehouse-id=abc123 + + # Create, deploy, and run with dev-remote + databricks experimental dev app init --name my-app --deploy --run=dev-remote + + # With a custom template from a local path + databricks experimental dev app init --template /path/to/template --name my-app + + # With a GitHub URL + databricks experimental dev app init --template https://github.com/user/repo --name my-app + +Feature dependencies: + Some features require additional flags: + - analytics: requires --warehouse-id (SQL Warehouse ID) + +Environment variables: + DATABRICKS_APPKIT_TEMPLATE_PATH Override template source with local path`, + Args: cobra.NoArgs, + PreRunE: root.MustWorkspaceClient, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + return runCreate(ctx, createOptions{ + templatePath: templatePath, + branch: branch, + name: name, + warehouseID: warehouseID, + description: description, + outputDir: outputDir, + features: features, + deploy: deploy, + run: run, + }) + }, + } + + cmd.Flags().StringVar(&templatePath, "template", "", "Template path (local directory or GitHub URL)") + cmd.Flags().StringVar(&branch, "branch", "", "Git branch or tag (for GitHub templates)") + cmd.Flags().StringVar(&name, "name", "", "Project name (prompts if not provided)") + cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID") + cmd.Flags().StringVar(&description, "description", "", "App description") + cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the project to") + cmd.Flags().StringSliceVar(&features, "features", nil, "Features to enable (comma-separated). Available: "+strings.Join(GetFeatureIDs(), ", ")) + cmd.Flags().BoolVar(&deploy, "deploy", false, "Deploy the app after creation") + cmd.Flags().StringVar(&run, "run", "", "Run the app after creation (none, dev, dev-remote)") + + return cmd +} + +type createOptions struct { + templatePath string + branch string + name string + warehouseID string + description string + outputDir string + features []string + deploy bool + run string +} + +// templateVars holds the variables for template substitution. +type templateVars struct { + ProjectName string + SQLWarehouseID string + AppDescription string + Profile string + WorkspaceHost string + PluginImport string + PluginUsage string + // Feature resource fragments (aggregated from selected features) + BundleVariables string + BundleResources string + TargetVariables string + AppEnv string + DotEnv string + DotEnvExample string +} + +// featureFragments holds aggregated content from feature resource files. +type featureFragments struct { + BundleVariables string + BundleResources string + TargetVariables string + AppEnv string + DotEnv string + DotEnvExample string +} + +// loadFeatureFragments reads and aggregates resource fragments for selected features. +// templateDir is the path to the template directory (containing the "features" subdirectory). +func loadFeatureFragments(templateDir string, featureIDs []string, vars templateVars) (*featureFragments, error) { + featuresDir := filepath.Join(templateDir, "features") + + resourceFiles := CollectResourceFiles(featureIDs) + if len(resourceFiles) == 0 { + return &featureFragments{}, nil + } + + var bundleVarsList, bundleResList, targetVarsList, appEnvList, dotEnvList, dotEnvExampleList []string + + for _, rf := range resourceFiles { + if rf.BundleVariables != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.BundleVariables), vars) + if err != nil { + return nil, fmt.Errorf("read bundle variables: %w", err) + } + bundleVarsList = append(bundleVarsList, content) + } + if rf.BundleResources != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.BundleResources), vars) + if err != nil { + return nil, fmt.Errorf("read bundle resources: %w", err) + } + bundleResList = append(bundleResList, content) + } + if rf.TargetVariables != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.TargetVariables), vars) + if err != nil { + return nil, fmt.Errorf("read target variables: %w", err) + } + targetVarsList = append(targetVarsList, content) + } + if rf.AppEnv != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.AppEnv), vars) + if err != nil { + return nil, fmt.Errorf("read app env: %w", err) + } + appEnvList = append(appEnvList, content) + } + if rf.DotEnv != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.DotEnv), vars) + if err != nil { + return nil, fmt.Errorf("read dotenv: %w", err) + } + dotEnvList = append(dotEnvList, content) + } + if rf.DotEnvExample != "" { + content, err := readAndSubstitute(filepath.Join(featuresDir, rf.DotEnvExample), vars) + if err != nil { + return nil, fmt.Errorf("read dotenv example: %w", err) + } + dotEnvExampleList = append(dotEnvExampleList, content) + } + } + + // Join fragments (they already have proper indentation from the fragment files) + return &featureFragments{ + BundleVariables: strings.TrimSuffix(strings.Join(bundleVarsList, ""), "\n"), + BundleResources: strings.TrimSuffix(strings.Join(bundleResList, ""), "\n"), + TargetVariables: strings.TrimSuffix(strings.Join(targetVarsList, ""), "\n"), + AppEnv: strings.TrimSuffix(strings.Join(appEnvList, ""), "\n"), + DotEnv: strings.TrimSuffix(strings.Join(dotEnvList, ""), "\n"), + DotEnvExample: strings.TrimSuffix(strings.Join(dotEnvExampleList, ""), "\n"), + }, nil +} + +// readAndSubstitute reads a file and applies variable substitution. +func readAndSubstitute(path string, vars templateVars) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil // Fragment file doesn't exist, skip it + } + return "", err + } + return substituteVars(string(content), vars), nil +} + +// parseGitHubURL extracts the repository URL, subdirectory, and branch from a GitHub URL. +// Input: https://github.com/user/repo/tree/main/templates/starter +// Output: repoURL="https://github.com/user/repo", subdir="templates/starter", branch="main" +func parseGitHubURL(url string) (repoURL, subdir, branch string) { + // Remove trailing slash + url = strings.TrimSuffix(url, "/") + + // Check for /tree/branch/path pattern + if idx := strings.Index(url, "/tree/"); idx != -1 { + repoURL = url[:idx] + rest := url[idx+6:] // Skip "/tree/" + + // Split into branch and path + parts := strings.SplitN(rest, "/", 2) + branch = parts[0] + if len(parts) > 1 { + subdir = parts[1] + } + return repoURL, subdir, branch + } + + // No /tree/ pattern, just a repo URL + return url, "", "" +} + +// cloneRepo clones a git repository to a temporary directory. +func cloneRepo(ctx context.Context, repoURL, branch string) (string, error) { + tempDir, err := os.MkdirTemp("", "appkit-template-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + args := []string{"clone", "--depth", "1"} + if branch != "" { + args = append(args, "--branch", branch) + } + args = append(args, repoURL, tempDir) + + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stdout = nil + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + os.RemoveAll(tempDir) + if stderr.Len() > 0 { + return "", fmt.Errorf("git clone failed: %s: %w", strings.TrimSpace(stderr.String()), err) + } + return "", fmt.Errorf("git clone failed: %w", err) + } + + return tempDir, nil +} + +// resolveTemplate resolves a template path, handling both local paths and GitHub URLs. +// Returns the local path to use, a cleanup function (for temp dirs), and any error. +func resolveTemplate(ctx context.Context, templatePath, branch string) (localPath string, cleanup func(), err error) { + // Case 1: Local path - return as-is + if !strings.HasPrefix(templatePath, "https://") { + return templatePath, nil, nil + } + + // Case 2: GitHub URL - parse and clone + repoURL, subdir, urlBranch := parseGitHubURL(templatePath) + if branch == "" { + branch = urlBranch // Use branch from URL if not overridden by flag + } + + // Clone to temp dir with spinner + var tempDir string + err = RunWithSpinnerCtx(ctx, "Cloning template...", func() error { + var cloneErr error + tempDir, cloneErr = cloneRepo(ctx, repoURL, branch) + return cloneErr + }) + if err != nil { + return "", nil, err + } + + cleanup = func() { os.RemoveAll(tempDir) } + + // Return path to subdirectory if specified + if subdir != "" { + return filepath.Join(tempDir, subdir), cleanup, nil + } + return tempDir, cleanup, nil +} + +func runCreate(ctx context.Context, opts createOptions) error { + var selectedFeatures []string + var dependencies map[string]string + var shouldDeploy bool + var runMode RunMode = RunModeNone + + // Use features from flags if provided + if len(opts.features) > 0 { + selectedFeatures = opts.features + } + + // Non-interactive mode: name provided via flag + if opts.name != "" { + // Build flag values map for dependency validation + flagValues := map[string]string{ + "warehouse-id": opts.warehouseID, + } + + // Validate that required dependencies are provided via flags + if len(selectedFeatures) > 0 { + if err := ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { + return err + } + } + + // Map flag values to dependencies + dependencies = make(map[string]string) + if opts.warehouseID != "" { + dependencies["sql_warehouse_id"] = opts.warehouseID + } + + // Use deploy and run flags + shouldDeploy = opts.deploy + switch opts.run { + case "dev": + runMode = RunModeDev + case "dev-remote": + runMode = RunModeDevRemote + case "", "none": + runMode = RunModeNone + default: + return fmt.Errorf("invalid --run value: %q (must be none, dev, or dev-remote)", opts.run) + } + } else { + // Interactive mode: prompt for everything + if !cmdio.IsPromptSupported(ctx) { + return errors.New("--name is required in non-interactive mode") + } + + // Pass pre-selected features to skip that prompt if already provided via flag + config, err := PromptForProjectConfig(ctx, selectedFeatures) + if err != nil { + return err + } + opts.name = config.ProjectName + if config.Description != "" { + opts.description = config.Description + } + // Use features from prompt if not already set via flag + if len(selectedFeatures) == 0 { + selectedFeatures = config.Features + } + dependencies = config.Dependencies + shouldDeploy = config.Deploy + runMode = config.RunMode + + // Get warehouse from dependencies if provided + if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { + opts.warehouseID = wh + } + } + + // Validate project name + if err := ValidateProjectName(opts.name); err != nil { + return err + } + + // Validate feature IDs + if err := ValidateFeatureIDs(selectedFeatures); err != nil { + return err + } + + // Set defaults + if opts.description == "" { + opts.description = DefaultAppDescription + } + + // Resolve template path (supports local paths and GitHub URLs) + templateSrc := opts.templatePath + if templateSrc == "" { + templateSrc = os.Getenv(templatePathEnvVar) + } + if templateSrc == "" { + return errors.New("template path required: set DATABRICKS_APPKIT_TEMPLATE_PATH or use --template flag") + } + + // Resolve template (handles GitHub URLs by cloning) + resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, opts.branch) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + // Check for generic subdirectory first (default for multi-template repos) + templateDir := filepath.Join(resolvedPath, "generic") + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + // Fall back to the provided path directly + templateDir = resolvedPath + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + return fmt.Errorf("template not found at %s (also checked %s/generic)", resolvedPath, resolvedPath) + } + } + + // Determine output directory + destDir := opts.name + if opts.outputDir != "" { + destDir = filepath.Join(opts.outputDir, opts.name) + } + + // Check if destination already exists + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("directory %s already exists", destDir) + } + + // Track whether we started creating the project for cleanup on failure + var projectCreated bool + var runErr error + defer func() { + if runErr != nil && projectCreated { + // Clean up partially created project on failure + os.RemoveAll(destDir) + } + }() + + // Get workspace host and profile from context + workspaceHost := "" + profile := "" + if w := cmdctx.WorkspaceClient(ctx); w != nil && w.Config != nil { + workspaceHost = w.Config.Host + profile = w.Config.Profile + } + + // Build plugin imports and usages from selected features + pluginImport, pluginUsage := BuildPluginStrings(selectedFeatures) + + // Template variables (initial, without feature fragments) + vars := templateVars{ + ProjectName: opts.name, + SQLWarehouseID: opts.warehouseID, + AppDescription: opts.description, + Profile: profile, + WorkspaceHost: workspaceHost, + PluginImport: pluginImport, + PluginUsage: pluginUsage, + } + + // Load feature resource fragments + fragments, err := loadFeatureFragments(templateDir, selectedFeatures, vars) + if err != nil { + return fmt.Errorf("load feature fragments: %w", err) + } + vars.BundleVariables = fragments.BundleVariables + vars.BundleResources = fragments.BundleResources + vars.TargetVariables = fragments.TargetVariables + vars.AppEnv = fragments.AppEnv + vars.DotEnv = fragments.DotEnv + vars.DotEnvExample = fragments.DotEnvExample + + // Copy template with variable substitution + var fileCount int + runErr = RunWithSpinnerCtx(ctx, "Creating project...", func() error { + var copyErr error + fileCount, copyErr = copyTemplate(templateDir, destDir, vars) + return copyErr + }) + if runErr != nil { + return runErr + } + projectCreated = true // From here on, cleanup on failure + + // Get absolute path + absOutputDir, err := filepath.Abs(destDir) + if err != nil { + absOutputDir = destDir + } + + // Apply features (adds selected features, removes unselected feature files) + runErr = RunWithSpinnerCtx(ctx, "Configuring features...", func() error { + return ApplyFeatures(absOutputDir, selectedFeatures) + }) + if runErr != nil { + return runErr + } + + // Run npm install + runErr = runNpmInstall(ctx, absOutputDir) + if runErr != nil { + return runErr + } + + // Run npm run setup + runErr = runNpmSetup(ctx, absOutputDir) + if runErr != nil { + return runErr + } + + // Show next steps only if user didn't choose to deploy or run + showNextSteps := !shouldDeploy && runMode == RunModeNone + PrintSuccess(opts.name, absOutputDir, fileCount, showNextSteps) + + // Execute post-creation actions (deploy and/or run) + if shouldDeploy || runMode != RunModeNone { + // Change to project directory for subsequent commands + if err := os.Chdir(absOutputDir); err != nil { + return fmt.Errorf("failed to change to project directory: %w", err) + } + } + + if shouldDeploy { + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Deploying app...") + if err := runPostCreateDeploy(ctx); err != nil { + cmdio.LogString(ctx, fmt.Sprintf("⚠ Deploy failed: %v", err)) + cmdio.LogString(ctx, " You can deploy manually with: databricks experimental dev app deploy") + } + } + + if runMode != RunModeNone { + cmdio.LogString(ctx, "") + if err := runPostCreateDev(ctx, runMode); err != nil { + return err + } + } + + return nil +} + +// runPostCreateDeploy runs the deploy command in the current directory. +func runPostCreateDeploy(ctx context.Context) error { + // Use os.Args[0] to get the path to the current executable + executable := os.Args[0] + cmd := exec.CommandContext(ctx, executable, "experimental", "dev", "app", "deploy") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +// runPostCreateDev runs the dev or dev-remote command in the current directory. +func runPostCreateDev(ctx context.Context, mode RunMode) error { + switch mode { + case RunModeDev: + cmdio.LogString(ctx, "Starting development server (npm run dev)...") + cmd := exec.CommandContext(ctx, "npm", "run", "dev") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() + case RunModeDevRemote: + cmdio.LogString(ctx, "Starting remote development server...") + // Use os.Args[0] to get the path to the current executable + executable := os.Args[0] + cmd := exec.CommandContext(ctx, executable, "experimental", "dev", "app", "dev-remote") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() + default: + return nil + } +} + +// runNpmInstall runs npm install in the project directory. +func runNpmInstall(ctx context.Context, projectDir string) error { + // Check if npm is available + if _, err := exec.LookPath("npm"); err != nil { + cmdio.LogString(ctx, "⚠ npm not found. Please install Node.js and run 'npm install' manually.") + return nil + } + + return RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + cmd := exec.CommandContext(ctx, "npm", "install") + cmd.Dir = projectDir + cmd.Stdout = nil // Suppress output + cmd.Stderr = nil + return cmd.Run() + }) +} + +// runNpmSetup runs npx appkit-setup in the project directory. +func runNpmSetup(ctx context.Context, projectDir string) error { + // Check if npx is available + if _, err := exec.LookPath("npx"); err != nil { + return nil + } + + return RunWithSpinnerCtx(ctx, "Running setup...", func() error { + cmd := exec.CommandContext(ctx, "npx", "appkit-setup", "--write") + cmd.Dir = projectDir + cmd.Stdout = nil // Suppress output + cmd.Stderr = nil + return cmd.Run() + }) +} + +// renameFiles maps source file names to destination names (for files that can't use special chars). +var renameFiles = map[string]string{ + "_gitignore": ".gitignore", + "_env": ".env", + "_env.local": ".env.local", + "_npmrc": ".npmrc", + "_prettierrc": ".prettierrc", + "_eslintrc": ".eslintrc", +} + +// copyTemplate copies the template directory to dest, substituting variables. +func copyTemplate(src, dest string, vars templateVars) (int, error) { + fileCount := 0 + + // Find the project_name placeholder directory + srcProjectDir := "" + entries, err := os.ReadDir(src) + if err != nil { + return 0, err + } + for _, e := range entries { + if e.IsDir() && strings.Contains(e.Name(), "{{.project_name}}") { + srcProjectDir = filepath.Join(src, e.Name()) + break + } + } + + // If no {{.project_name}} dir found, copy src directly + if srcProjectDir == "" { + srcProjectDir = src + } + + // Files and directories to skip + skipFiles := map[string]bool{ + "CLAUDE.md": true, + "AGENTS.md": true, + "databricks_template_schema.json": true, + } + skipDirs := map[string]bool{ + "docs": true, + "features": true, // Feature fragments are processed separately, not copied + } + + err = filepath.Walk(srcProjectDir, func(srcPath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + baseName := filepath.Base(srcPath) + + // Skip certain files + if skipFiles[baseName] { + return nil + } + + // Skip certain directories + if info.IsDir() && skipDirs[baseName] { + return filepath.SkipDir + } + + // Calculate relative path from source project dir + relPath, err := filepath.Rel(srcProjectDir, srcPath) + if err != nil { + return err + } + + // Substitute variables in path + relPath = substituteVars(relPath, vars) + + // Handle .tmpl extension - strip it + relPath = strings.TrimSuffix(relPath, ".tmpl") + + // Apply file renames (e.g., _gitignore -> .gitignore) + fileName := filepath.Base(relPath) + if newName, ok := renameFiles[fileName]; ok { + relPath = filepath.Join(filepath.Dir(relPath), newName) + } + + destPath := filepath.Join(dest, relPath) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + // Read file content + content, err := os.ReadFile(srcPath) + if err != nil { + return err + } + + // Handle special files + switch filepath.Base(srcPath) { + case "package.json": + content, err = processPackageJSON(content, vars) + if err != nil { + return fmt.Errorf("process package.json: %w", err) + } + default: + // Use Go template engine for .tmpl files (handles conditionals) + if strings.HasSuffix(srcPath, ".tmpl") { + content, err = executeTemplate(srcPath, content, vars) + if err != nil { + return fmt.Errorf("process template %s: %w", srcPath, err) + } + } else if isTextFile(srcPath) { + // Simple substitution for other text files + content = []byte(substituteVars(string(content), vars)) + } + } + + // Create parent directory + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + + // Write file + if err := os.WriteFile(destPath, content, info.Mode()); err != nil { + return err + } + + fileCount++ + return nil + }) + + return fileCount, err +} + +// processPackageJSON updates the package.json with project-specific values. +func processPackageJSON(content []byte, vars templateVars) ([]byte, error) { + // Just do string substitution to preserve key order and formatting + return []byte(substituteVars(string(content), vars)), nil +} + +// substituteVars replaces template variables in a string. +func substituteVars(s string, vars templateVars) string { + s = strings.ReplaceAll(s, "{{.project_name}}", vars.ProjectName) + s = strings.ReplaceAll(s, "{{.sql_warehouse_id}}", vars.SQLWarehouseID) + s = strings.ReplaceAll(s, "{{.app_description}}", vars.AppDescription) + s = strings.ReplaceAll(s, "{{.profile}}", vars.Profile) + s = strings.ReplaceAll(s, "{{workspace_host}}", vars.WorkspaceHost) + + // Handle plugin placeholders + if vars.PluginImport != "" { + s = strings.ReplaceAll(s, "{{.plugin_import}}", vars.PluginImport) + s = strings.ReplaceAll(s, "{{.plugin_usage}}", vars.PluginUsage) + } else { + // No plugins selected - clean up the template + // Remove ", {{.plugin_import}}" from import line + s = strings.ReplaceAll(s, ", {{.plugin_import}} ", " ") + s = strings.ReplaceAll(s, ", {{.plugin_import}}", "") + // Remove the plugin_usage line entirely + s = strings.ReplaceAll(s, " {{.plugin_usage}},\n", "") + s = strings.ReplaceAll(s, " {{.plugin_usage}},", "") + } + + return s +} + +// executeTemplate processes a .tmpl file using Go's text/template engine. +func executeTemplate(path string, content []byte, vars templateVars) ([]byte, error) { + tmpl, err := template.New(filepath.Base(path)). + Funcs(template.FuncMap{ + "workspace_host": func() string { return vars.WorkspaceHost }, + }). + Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + + // Use a map to match template variable names exactly (snake_case) + data := map[string]string{ + "project_name": vars.ProjectName, + "sql_warehouse_id": vars.SQLWarehouseID, + "app_description": vars.AppDescription, + "profile": vars.Profile, + "workspace_host": vars.WorkspaceHost, + "plugin_import": vars.PluginImport, + "plugin_usage": vars.PluginUsage, + "bundle_variables": vars.BundleVariables, + "bundle_resources": vars.BundleResources, + "target_variables": vars.TargetVariables, + "app_env": vars.AppEnv, + "dotenv": vars.DotEnv, + "dotenv_example": vars.DotEnvExample, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("execute template: %w", err) + } + + return buf.Bytes(), nil +} + +// textExtensions contains file extensions that should be treated as text files. +var textExtensions = map[string]bool{ + ".ts": true, ".tsx": true, ".js": true, ".jsx": true, + ".json": true, ".yaml": true, ".yml": true, + ".md": true, ".txt": true, ".html": true, ".css": true, + ".scss": true, ".less": true, ".sql": true, + ".sh": true, ".bash": true, ".zsh": true, + ".py": true, ".go": true, ".rs": true, + ".toml": true, ".ini": true, ".cfg": true, + ".env": true, ".gitignore": true, ".npmrc": true, + ".prettierrc": true, ".eslintrc": true, +} + +// textBaseNames contains file names (without extension) that should be treated as text files. +var textBaseNames = map[string]bool{ + "Makefile": true, "Dockerfile": true, "LICENSE": true, + "README": true, ".gitignore": true, ".env": true, + ".nvmrc": true, ".node-version": true, + "_gitignore": true, "_env": true, "_npmrc": true, +} + +// isTextFile checks if a file is likely a text file based on extension. +func isTextFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + if textExtensions[ext] { + return true + } + return textBaseNames[filepath.Base(path)] +} diff --git a/experimental/dev/cmd/app/init_test.go b/experimental/dev/cmd/app/init_test.go new file mode 100644 index 0000000000..138b92f908 --- /dev/null +++ b/experimental/dev/cmd/app/init_test.go @@ -0,0 +1,238 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseGitHubURL(t *testing.T) { + tests := []struct { + name string + url string + wantRepoURL string + wantSubdir string + wantBranch string + }{ + { + name: "simple repo URL", + url: "https://github.com/user/repo", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "", + wantBranch: "", + }, + { + name: "repo URL with trailing slash", + url: "https://github.com/user/repo/", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "", + wantBranch: "", + }, + { + name: "repo with branch", + url: "https://github.com/user/repo/tree/main", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "", + wantBranch: "main", + }, + { + name: "repo with branch and subdir", + url: "https://github.com/user/repo/tree/main/templates/starter", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "templates/starter", + wantBranch: "main", + }, + { + name: "repo with branch and deep subdir", + url: "https://github.com/databricks/cli/tree/v0.1.0/libs/template/templates/default-python", + wantRepoURL: "https://github.com/databricks/cli", + wantSubdir: "libs/template/templates/default-python", + wantBranch: "v0.1.0", + }, + { + name: "repo with feature branch", + url: "https://github.com/user/repo/tree/feature/my-feature", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "my-feature", + wantBranch: "feature", + }, + { + name: "repo URL with trailing slash and tree", + url: "https://github.com/user/repo/tree/main/", + wantRepoURL: "https://github.com/user/repo", + wantSubdir: "", + wantBranch: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepoURL, gotSubdir, gotBranch := parseGitHubURL(tt.url) + assert.Equal(t, tt.wantRepoURL, gotRepoURL, "repoURL mismatch") + assert.Equal(t, tt.wantSubdir, gotSubdir, "subdir mismatch") + assert.Equal(t, tt.wantBranch, gotBranch, "branch mismatch") + }) + } +} + +func TestIsTextFile(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + // Text files by extension + {"file.ts", true}, + {"file.tsx", true}, + {"file.js", true}, + {"file.jsx", true}, + {"file.json", true}, + {"file.yaml", true}, + {"file.yml", true}, + {"file.md", true}, + {"file.txt", true}, + {"file.html", true}, + {"file.css", true}, + {"file.scss", true}, + {"file.sql", true}, + {"file.sh", true}, + {"file.py", true}, + {"file.go", true}, + {"file.toml", true}, + {"file.env", true}, + + // Text files by name + {"Makefile", true}, + {"Dockerfile", true}, + {"LICENSE", true}, + {"README", true}, + {".gitignore", true}, + {".env", true}, + {"_gitignore", true}, + {"_env", true}, + + // Binary files (should return false) + {"file.png", false}, + {"file.jpg", false}, + {"file.gif", false}, + {"file.pdf", false}, + {"file.exe", false}, + {"file.bin", false}, + {"file.zip", false}, + {"randomfile", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := isTextFile(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSubstituteVars(t *testing.T) { + vars := templateVars{ + ProjectName: "my-app", + SQLWarehouseID: "warehouse123", + AppDescription: "My awesome app", + Profile: "default", + WorkspaceHost: "https://dbc-123.cloud.databricks.com", + PluginImport: "analytics", + PluginUsage: "analytics()", + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "project name substitution", + input: "name: {{.project_name}}", + expected: "name: my-app", + }, + { + name: "warehouse id substitution", + input: "warehouse: {{.sql_warehouse_id}}", + expected: "warehouse: warehouse123", + }, + { + name: "description substitution", + input: "description: {{.app_description}}", + expected: "description: My awesome app", + }, + { + name: "profile substitution", + input: "profile: {{.profile}}", + expected: "profile: default", + }, + { + name: "workspace host substitution", + input: "host: {{workspace_host}}", + expected: "host: https://dbc-123.cloud.databricks.com", + }, + { + name: "plugin import substitution", + input: "import { {{.plugin_import}} } from 'appkit'", + expected: "import { analytics } from 'appkit'", + }, + { + name: "plugin usage substitution", + input: "plugins: [{{.plugin_usage}}]", + expected: "plugins: [analytics()]", + }, + { + name: "multiple substitutions", + input: "{{.project_name}} - {{.app_description}}", + expected: "my-app - My awesome app", + }, + { + name: "no substitutions needed", + input: "plain text without variables", + expected: "plain text without variables", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := substituteVars(tt.input, vars) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSubstituteVarsNoPlugins(t *testing.T) { + // Test plugin cleanup when no plugins are selected + vars := templateVars{ + ProjectName: "my-app", + SQLWarehouseID: "", + AppDescription: "My app", + Profile: "", + WorkspaceHost: "", + PluginImport: "", // No plugins + PluginUsage: "", + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes plugin import with comma", + input: "import { core, {{.plugin_import}} } from 'appkit'", + expected: "import { core } from 'appkit'", + }, + { + name: "removes plugin usage line", + input: "plugins: [\n {{.plugin_usage}},\n]", + expected: "plugins: [\n]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := substituteVars(tt.input, vars) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/experimental/dev/cmd/app/prompt.go b/experimental/dev/cmd/app/prompt.go new file mode 100644 index 0000000000..5a974ba43e --- /dev/null +++ b/experimental/dev/cmd/app/prompt.go @@ -0,0 +1,419 @@ +package app + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "time" + + "github.com/briandowns/spinner" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go/listing" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/databricks/databricks-sdk-go/service/sql" +) + +// DefaultAppDescription is the default description for new apps. +const DefaultAppDescription = "A Databricks App powered by AppKit" + +// AppkitTheme returns a custom theme for appkit prompts. +func appkitTheme() *huh.Theme { + t := huh.ThemeBase() + + // Databricks brand colors + red := lipgloss.Color("#BD2B26") + gray := lipgloss.Color("#71717A") // Mid-tone gray, readable on light and dark + yellow := lipgloss.Color("#FFAB00") + + t.Focused.Title = t.Focused.Title.Foreground(red).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(gray) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(yellow) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(gray) + + return t +} + +// RunMode specifies how to run the app after creation. +type RunMode string + +const ( + RunModeNone RunMode = "none" + RunModeDev RunMode = "dev" + RunModeDevRemote RunMode = "dev-remote" +) + +// CreateProjectConfig holds the configuration gathered from the interactive prompt. +type CreateProjectConfig struct { + ProjectName string + Description string + Features []string + Dependencies map[string]string // e.g., {"sql_warehouse_id": "abc123"} + Deploy bool // Whether to deploy the app after creation + RunMode RunMode // How to run the app after creation +} + +// App name constraints. +const ( + MaxAppNameLength = 30 + DevTargetPrefix = "dev-" +) + +// projectNamePattern is the compiled regex for validating project names. +// Pre-compiled for efficiency since validation is called on every keystroke. +var projectNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// ValidateProjectName validates the project name for length and pattern constraints. +// It checks that the name plus the "dev-" prefix doesn't exceed 30 characters, +// and that the name follows the pattern: starts with a letter, contains only +// lowercase letters, numbers, or hyphens. +func ValidateProjectName(s string) error { + if s == "" { + return errors.New("project name is required") + } + + // Check length constraint (dev- prefix + name <= 30) + totalLength := len(DevTargetPrefix) + len(s) + if totalLength > MaxAppNameLength { + maxAllowed := MaxAppNameLength - len(DevTargetPrefix) + return fmt.Errorf("name too long (max %d chars)", maxAllowed) + } + + // Check pattern + if !projectNamePattern.MatchString(s) { + return errors.New("must start with a letter, use only lowercase letters, numbers, or hyphens") + } + + return nil +} + +// PromptForProjectConfig shows an interactive form to gather project configuration. +// Flow: name -> features -> feature dependencies -> description. +// If preSelectedFeatures is provided, the feature selection prompt is skipped. +func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { + config := &CreateProjectConfig{ + Dependencies: make(map[string]string), + Features: preSelectedFeatures, + } + theme := appkitTheme() + + // Header + headerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#BD2B26")). + Bold(true) + + subtitleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#71717A")) + + fmt.Println() + fmt.Println(headerStyle.Render("ā—† Create a new Databricks AppKit project")) + fmt.Println(subtitleStyle.Render(" Full-stack TypeScript • React • Tailwind CSS")) + fmt.Println() + + // Step 1: Project name + err := huh.NewInput(). + Title("Project name"). + Description("lowercase letters, numbers, hyphens (max 26 chars)"). + Placeholder("my-app"). + Value(&config.ProjectName). + Validate(ValidateProjectName). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + + // Step 2: Feature selection (skip if features already provided via flag) + if len(config.Features) == 0 && len(AvailableFeatures) > 0 { + options := make([]huh.Option[string], 0, len(AvailableFeatures)) + for _, f := range AvailableFeatures { + label := f.Name + " - " + f.Description + options = append(options, huh.NewOption(label, f.ID)) + } + + err = huh.NewMultiSelect[string](). + Title("Select features"). + Description("space to toggle, enter to confirm"). + Options(options...). + Value(&config.Features). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + } + + // Step 3: Prompt for feature dependencies + deps := CollectDependencies(config.Features) + for _, dep := range deps { + // Special handling for SQL warehouse - show picker instead of text input + if dep.ID == "sql_warehouse_id" { + warehouseID, err := PromptForWarehouse(ctx) + if err != nil { + return nil, err + } + config.Dependencies[dep.ID] = warehouseID + continue + } + + var value string + description := dep.Description + if !dep.Required { + description += " (optional)" + } + + input := huh.NewInput(). + Title(dep.Title). + Description(description). + Placeholder(dep.Placeholder). + Value(&value) + + if dep.Required { + input = input.Validate(func(s string) error { + if s == "" { + return errors.New("this field is required") + } + return nil + }) + } + + if err := input.WithTheme(theme).Run(); err != nil { + return nil, err + } + config.Dependencies[dep.ID] = value + } + + // Step 4: Description + config.Description = DefaultAppDescription + + err = huh.NewInput(). + Title("Description"). + Placeholder(DefaultAppDescription). + Value(&config.Description). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + + if config.Description == "" { + config.Description = DefaultAppDescription + } + + // Step 5: Deploy after creation? + err = huh.NewConfirm(). + Title("Deploy after creation?"). + Description("Run 'databricks experimental dev app deploy' after setup"). + Value(&config.Deploy). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + + // Step 6: Run the app? + runModeStr := string(RunModeNone) + err = huh.NewSelect[string](). + Title("Run the app after creation?"). + Description("Choose how to start the development server"). + Options( + huh.NewOption("No, I'll run it later", string(RunModeNone)), + huh.NewOption("Yes, run locally (npm run dev)", string(RunModeDev)), + huh.NewOption("Yes, run with remote bridge (dev-remote)", string(RunModeDevRemote)), + ). + Value(&runModeStr). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + config.RunMode = RunMode(runModeStr) + + return config, nil +} + +// ListSQLWarehouses fetches all SQL warehouses the user has access to. +func ListSQLWarehouses(ctx context.Context) ([]sql.EndpointInfo, error) { + w := cmdctx.WorkspaceClient(ctx) + if w == nil { + return nil, errors.New("no workspace client available") + } + + iter := w.Warehouses.List(ctx, sql.ListWarehousesRequest{}) + return listing.ToSlice(ctx, iter) +} + +// PromptForWarehouse shows a picker to select a SQL warehouse. +func PromptForWarehouse(ctx context.Context) (string, error) { + var warehouses []sql.EndpointInfo + err := RunWithSpinnerCtx(ctx, "Fetching SQL warehouses...", func() error { + var fetchErr error + warehouses, fetchErr = ListSQLWarehouses(ctx) + return fetchErr + }) + if err != nil { + return "", fmt.Errorf("failed to fetch SQL warehouses: %w", err) + } + + if len(warehouses) == 0 { + return "", errors.New("no SQL warehouses found. Create one in your workspace first") + } + + theme := appkitTheme() + + // Build options with warehouse name and state + options := make([]huh.Option[string], 0, len(warehouses)) + for _, wh := range warehouses { + state := string(wh.State) + label := fmt.Sprintf("%s (%s)", wh.Name, state) + options = append(options, huh.NewOption(label, wh.Id)) + } + + var selected string + err = huh.NewSelect[string](). + Title("Select SQL Warehouse"). + Description(fmt.Sprintf("%d warehouses available — type to filter", len(warehouses))). + Options(options...). + Value(&selected). + Filtering(true). + WithTheme(theme). + Run() + if err != nil { + return "", err + } + + return selected, nil +} + +// RunWithSpinnerCtx runs a function while showing a spinner with the given title. +// The spinner stops and the function returns early if the context is cancelled. +// Panics in the action are recovered and returned as errors. +func RunWithSpinnerCtx(ctx context.Context, title string, action func() error) error { + s := spinner.New( + spinner.CharSets[14], + 80*time.Millisecond, + spinner.WithColor("yellow"), // Databricks brand color + spinner.WithSuffix(" "+title), + ) + s.Start() + + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("action panicked: %v", r) + } + }() + done <- action() + }() + + select { + case err := <-done: + s.Stop() + return err + case <-ctx.Done(): + s.Stop() + // Wait for action goroutine to complete to avoid orphaned goroutines. + // For exec.CommandContext, the process is killed when context is cancelled. + <-done + return ctx.Err() + } +} + +// ListAllApps fetches all apps the user has access to from the workspace. +func ListAllApps(ctx context.Context) ([]apps.App, error) { + w := cmdctx.WorkspaceClient(ctx) + if w == nil { + return nil, errors.New("no workspace client available") + } + + iter := w.Apps.List(ctx, apps.ListAppsRequest{}) + return listing.ToSlice(ctx, iter) +} + +// PromptForAppSelection shows a picker to select an existing app. +// Returns the selected app name or error if cancelled/no apps found. +func PromptForAppSelection(ctx context.Context, title string) (string, error) { + if !cmdio.IsPromptSupported(ctx) { + return "", errors.New("--name is required in non-interactive mode") + } + + // Fetch all apps the user has access to + var existingApps []apps.App + err := RunWithSpinnerCtx(ctx, "Fetching apps...", func() error { + var fetchErr error + existingApps, fetchErr = ListAllApps(ctx) + return fetchErr + }) + if err != nil { + return "", fmt.Errorf("failed to fetch apps: %w", err) + } + + if len(existingApps) == 0 { + return "", errors.New("no apps found. Create one first with 'databricks apps create '") + } + + theme := appkitTheme() + + // Build options + options := make([]huh.Option[string], 0, len(existingApps)) + for _, app := range existingApps { + label := app.Name + if app.Description != "" { + desc := app.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + label += " — " + desc + } + options = append(options, huh.NewOption(label, app.Name)) + } + + var selected string + err = huh.NewSelect[string](). + Title(title). + Description(fmt.Sprintf("%d apps found — type to filter", len(existingApps))). + Options(options...). + Value(&selected). + Filtering(true). + WithTheme(theme). + Run() + if err != nil { + return "", err + } + + return selected, nil +} + +// PrintSuccess prints a success message after project creation. +// If showNextSteps is true, also prints the "Next steps" section. +func PrintSuccess(projectName, outputDir string, fileCount int, showNextSteps bool) { + successStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFAB00")). // Databricks yellow + Bold(true) + + dimStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#71717A")) // Mid-tone gray + + codeStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF3621")) // Databricks orange + + fmt.Println() + fmt.Println(successStyle.Render("āœ” Project created successfully!")) + fmt.Println() + fmt.Println(dimStyle.Render(" Location: " + outputDir)) + fmt.Println(dimStyle.Render(" Files: " + strconv.Itoa(fileCount))) + + if showNextSteps { + fmt.Println() + fmt.Println(dimStyle.Render(" Next steps:")) + fmt.Println() + fmt.Println(codeStyle.Render(" cd " + projectName)) + fmt.Println(codeStyle.Render(" npm run dev")) + } + fmt.Println() +} diff --git a/experimental/dev/cmd/app/prompt_test.go b/experimental/dev/cmd/app/prompt_test.go new file mode 100644 index 0000000000..731095533a --- /dev/null +++ b/experimental/dev/cmd/app/prompt_test.go @@ -0,0 +1,187 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateProjectName(t *testing.T) { + tests := []struct { + name string + projectName string + expectError bool + errorMsg string + }{ + { + name: "valid simple name", + projectName: "my-app", + expectError: false, + }, + { + name: "valid name with numbers", + projectName: "app123", + expectError: false, + }, + { + name: "valid name with hyphens", + projectName: "my-cool-app", + expectError: false, + }, + { + name: "empty name", + projectName: "", + expectError: true, + errorMsg: "required", + }, + { + name: "name too long", + projectName: "this-is-a-very-long-app-name-that-exceeds", + expectError: true, + errorMsg: "too long", + }, + { + name: "name at max length (26 chars)", + projectName: "abcdefghijklmnopqrstuvwxyz", + expectError: false, + }, + { + name: "name starts with number", + projectName: "123app", + expectError: true, + errorMsg: "must start with a letter", + }, + { + name: "name starts with hyphen", + projectName: "-myapp", + expectError: true, + errorMsg: "must start with a letter", + }, + { + name: "name with uppercase", + projectName: "MyApp", + expectError: true, + errorMsg: "lowercase", + }, + { + name: "name with underscore", + projectName: "my_app", + expectError: true, + errorMsg: "lowercase letters, numbers, or hyphens", + }, + { + name: "name with spaces", + projectName: "my app", + expectError: true, + errorMsg: "lowercase letters, numbers, or hyphens", + }, + { + name: "name with special characters", + projectName: "my@app!", + expectError: true, + errorMsg: "lowercase letters, numbers, or hyphens", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateProjectName(tt.projectName) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRunWithSpinnerCtx(t *testing.T) { + t.Run("successful action", func(t *testing.T) { + ctx := context.Background() + executed := false + + err := RunWithSpinnerCtx(ctx, "Testing...", func() error { + executed = true + return nil + }) + + assert.NoError(t, err) + assert.True(t, executed) + }) + + t.Run("action returns error", func(t *testing.T) { + ctx := context.Background() + expectedErr := errors.New("action failed") + + err := RunWithSpinnerCtx(ctx, "Testing...", func() error { + return expectedErr + }) + + assert.Equal(t, expectedErr, err) + }) + + t.Run("context cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + actionStarted := make(chan struct{}) + actionDone := make(chan struct{}) + + go func() { + _ = RunWithSpinnerCtx(ctx, "Testing...", func() error { + close(actionStarted) + time.Sleep(100 * time.Millisecond) + close(actionDone) + return nil + }) + }() + + // Wait for action to start + <-actionStarted + // Cancel context + cancel() + // Wait for action to complete (spinner should wait) + <-actionDone + }) + + t.Run("action panics - recovered", func(t *testing.T) { + ctx := context.Background() + + err := RunWithSpinnerCtx(ctx, "Testing...", func() error { + panic("test panic") + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "action panicked") + assert.Contains(t, err.Error(), "test panic") + }) +} + +func TestRunModeConstants(t *testing.T) { + assert.Equal(t, RunMode("none"), RunModeNone) + assert.Equal(t, RunMode("dev"), RunModeDev) + assert.Equal(t, RunMode("dev-remote"), RunModeDevRemote) +} + +func TestMaxAppNameLength(t *testing.T) { + // Verify the constant is set correctly + assert.Equal(t, 30, MaxAppNameLength) + assert.Equal(t, "dev-", DevTargetPrefix) + + // Max allowed name length should be 30 - 4 ("dev-") = 26 + maxAllowed := MaxAppNameLength - len(DevTargetPrefix) + assert.Equal(t, 26, maxAllowed) + + // Test at boundary + validName := "abcdefghijklmnopqrstuvwxyz" // 26 chars + assert.Len(t, validName, 26) + assert.NoError(t, ValidateProjectName(validName)) + + // Test over boundary + invalidName := "abcdefghijklmnopqrstuvwxyz1" // 27 chars + assert.Len(t, invalidName, 27) + assert.Error(t, ValidateProjectName(invalidName)) +} diff --git a/experimental/dev/cmd/app/vite-server.js b/experimental/dev/cmd/app/vite-server.js new file mode 100644 index 0000000000..e0ba85322a --- /dev/null +++ b/experimental/dev/cmd/app/vite-server.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +const path = require("node:path"); +const fs = require("node:fs"); + +async function startViteServer() { + const vitePath = safeViteResolve(); + + if (!vitePath) { + console.log( + "\nāŒ Vite needs to be installed in the current directory. Run `npm install vite`.\n" + ); + process.exit(1); + } + + const { createServer, loadConfigFromFile, mergeConfig } = require(vitePath); + + /** + * This script is controlled by us, and shouldn't be called directly by the user. + * We know the order of the arguments is always: + * 1. appUrl + * 2. port + * + * We can safely access the arguments by index. + */ + const clientPath = path.join(process.cwd(), "client"); + const appUrl = process.argv[2] || ""; + const port = parseInt(process.argv[3] || 5173); + + if (!fs.existsSync(clientPath)) { + console.error("client folder doesn't exist."); + process.exit(1); + } + + if (!appUrl) { + console.error("App URL is required"); + process.exit(1); + } + + try { + const domain = new URL(appUrl); + + const loadedConfig = await loadConfigFromFile( + { + mode: "development", + command: "serve", + }, + undefined, + clientPath + ); + const userConfig = loadedConfig?.config ?? {}; + + /** + * Vite uses the same port for the HMR server as the main server. + * Allowing the user to set this option breaks the system. + * By just providing the port override option, Vite will use the same port for the HMR server. + * Multiple servers will work, but if the user has this in their config we need to delete it. + */ + delete userConfig.server?.hmr?.port; + + const coreConfig = { + configFile: false, + root: clientPath, + server: { + open: `${domain.origin}?dev=true`, + port: port, + hmr: { + overlay: true, + path: `/dev-hmr`, + }, + middlewareMode: false, + }, + plugins: [queriesHMRPlugin()], + }; + const mergedConfigs = mergeConfig(userConfig, coreConfig); + const server = await createServer(mergedConfigs); + + await server.listen(); + + console.log(`\nāœ… Vite dev server started successfully!`); + console.log(`\nPress Ctrl+C to stop the server\n`); + + const shutdown = async () => { + await server.close(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } catch (error) { + console.error(`āŒ Failed to start Vite server:`, error.message); + if (error.stack) { + console.error(error.stack); + } + process.exit(1); + } +} + +function safeViteResolve() { + try { + const vitePath = require.resolve("vite", { paths: [process.cwd()] }); + + return vitePath; + } catch (error) { + return null; + } +} + +// Start the server +startViteServer().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); + +/* + * development only, watches for changes in the queries directory and sends HMR updates to the client. + */ +function queriesHMRPlugin(options = {}) { + const { queriesPath = path.resolve(process.cwd(), "config/queries") } = + options; + let isServe = false; + let serverRunning = false; + + return { + name: "queries-hmr", + async buildStart() { + if (!isServe) return; + if (serverRunning) { + return; + } + serverRunning = true; + }, + configResolved(config) { + isServe = config.command === "serve"; + }, + configureServer(server) { + if (!isServe) return; + if (!server.config.mode || server.config.mode === "development") { + // 1. check if queries directory exists + if (fs.existsSync(queriesPath)) { + // 2. add the queries directory to the watcher + server.watcher.add(queriesPath); + + const handleFileChange = (file) => { + if (file.includes("config/queries") && file.endsWith(".sql")) { + const fileName = path.basename(file); + const queryKey = fileName.replace(/\.(sql)$/, ""); + + console.log("šŸ”„ Query updated:", queryKey, fileName); + + server.ws.send({ + type: "custom", + event: "query-update", + data: { + key: queryKey, + timestamp: Date.now(), + }, + }); + } + }; + + server.watcher.on("change", handleFileChange); + } + + process.on("SIGINT", () => { + console.log("šŸ›‘ SIGINT received — cleaning up before exit..."); + serverRunning = false; + process.exit(0); + }); + } + }, + }; +} diff --git a/experimental/dev/cmd/app/vite_bridge.go b/experimental/dev/cmd/app/vite_bridge.go new file mode 100644 index 0000000000..cddc6d02d6 --- /dev/null +++ b/experimental/dev/cmd/app/vite_bridge.go @@ -0,0 +1,838 @@ +package app + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/gorilla/websocket" + "golang.org/x/sync/errgroup" +) + +const ( + localViteURL = "http://localhost:%d" + localViteHMRURL = "ws://localhost:%d/dev-hmr" + viteHMRProtocol = "vite-hmr" + + // WebSocket timeouts + wsHandshakeTimeout = 45 * time.Second + wsKeepaliveInterval = 20 * time.Second + wsWriteTimeout = 5 * time.Second + + // HTTP client timeouts + httpRequestTimeout = 60 * time.Second + httpIdleConnTimeout = 90 * time.Second + + // Bridge operation timeouts + bridgeFetchTimeout = 30 * time.Second + bridgeConnTimeout = 60 * time.Second + bridgeTunnelReadyTimeout = 30 * time.Second + + // Retry configuration + tunnelConnectMaxRetries = 5 + tunnelConnectInitialBackoff = 2 * time.Second + tunnelConnectMaxBackoff = 30 * time.Second +) + +type ViteBridgeMessage struct { + Type string `json:"type"` + TunnelID string `json:"tunnelId,omitempty"` + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + Status int `json:"status,omitempty"` + Headers map[string]any `json:"headers,omitempty"` + Body string `json:"body,omitempty"` + Viewer string `json:"viewer"` + RequestID string `json:"requestId"` + Approved bool `json:"approved"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` +} + +// prioritizedMessage represents a message to send through the tunnel websocket +type prioritizedMessage struct { + messageType int + data []byte + priority int // 0 = high (HMR), 1 = normal (fetch) +} + +type ViteBridge struct { + ctx context.Context + w *databricks.WorkspaceClient + appName string + tunnelConn *websocket.Conn + hmrConn *websocket.Conn + tunnelID string + tunnelWriteChan chan prioritizedMessage + stopChan chan struct{} + stopOnce sync.Once + httpClient *http.Client + connectionRequests chan *ViteBridgeMessage + port int + keepaliveDone chan struct{} // Signals keepalive goroutine to stop on reconnect + keepaliveMu sync.Mutex // Protects keepaliveDone +} + +func NewViteBridge(ctx context.Context, w *databricks.WorkspaceClient, appName string, port int) *ViteBridge { + // Configure HTTP client optimized for local high-volume requests + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: httpIdleConnTimeout, + DisableKeepAlives: false, + DisableCompression: false, + } + + return &ViteBridge{ + ctx: ctx, + w: w, + appName: appName, + httpClient: &http.Client{ + Timeout: httpRequestTimeout, + Transport: transport, + }, + stopChan: make(chan struct{}), + tunnelWriteChan: make(chan prioritizedMessage, 100), // Buffered channel for async writes + connectionRequests: make(chan *ViteBridgeMessage, 10), + port: port, + } +} + +func (vb *ViteBridge) getAuthHeaders(wsURL string) (http.Header, error) { + req, err := http.NewRequestWithContext(vb.ctx, "GET", wsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + err = vb.w.Config.Authenticate(req) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: %w", err) + } + + return req.Header, nil +} + +func (vb *ViteBridge) GetAppDomain() (*url.URL, error) { + app, err := vb.w.Apps.Get(vb.ctx, apps.GetAppRequest{ + Name: vb.appName, + }) + if err != nil { + return nil, fmt.Errorf("failed to get app: %w", err) + } + + if app.Url == "" { + return nil, errors.New("app URL is empty") + } + + return url.Parse(app.Url) +} + +func (vb *ViteBridge) connectToTunnel(appDomain *url.URL) error { + wsURL := fmt.Sprintf("wss://%s/dev-tunnel", appDomain.Host) + + headers, err := vb.getAuthHeaders(wsURL) + if err != nil { + return fmt.Errorf("failed to get auth headers: %w", err) + } + + dialer := websocket.Dialer{ + HandshakeTimeout: wsHandshakeTimeout, + ReadBufferSize: 256 * 1024, // 256KB read buffer for large assets + WriteBufferSize: 256 * 1024, // 256KB write buffer for large assets + } + + conn, resp, err := dialer.Dial(wsURL, headers) + if err != nil { + if resp != nil { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return fmt.Errorf("failed to connect to tunnel (status %d): %w, body: %s", resp.StatusCode, err, string(body)) + } + return fmt.Errorf("failed to connect to tunnel: %w", err) + } + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + + // Configure keepalive to prevent server timeout + _ = conn.SetReadDeadline(time.Time{}) // No read timeout + _ = conn.SetWriteDeadline(time.Time{}) // No write timeout + + // Enable pong handler to respond to server pongs (response to our pings) + conn.SetPongHandler(func(appData string) error { + log.Debugf(vb.ctx, "[vite_bridge] Received pong from server") + return nil + }) + + // Enable ping handler to respond to server pings with pongs + conn.SetPingHandler(func(appData string) error { + log.Debugf(vb.ctx, "[vite_bridge] Received ping from server, sending pong") + // Send pong response + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.PongMessage, + data: []byte(appData), + priority: 0, // High priority + }: + case <-time.After(wsWriteTimeout): + log.Warnf(vb.ctx, "[vite_bridge] Failed to send pong response") + } + return nil + }) + + vb.tunnelConn = conn + + // Start keepalive ping goroutine (stop existing one first if any) + vb.keepaliveMu.Lock() + if vb.keepaliveDone != nil { + close(vb.keepaliveDone) + } + vb.keepaliveDone = make(chan struct{}) + keepaliveDone := vb.keepaliveDone + vb.keepaliveMu.Unlock() + + go vb.tunnelKeepalive(keepaliveDone) + + return nil +} + +// connectToTunnelWithRetry attempts to connect to the tunnel with exponential backoff. +// This handles cases where the app isn't fully ready yet (e.g., right after deployment). +func (vb *ViteBridge) connectToTunnelWithRetry(appDomain *url.URL) error { + var lastErr error + backoff := tunnelConnectInitialBackoff + + for attempt := 1; attempt <= tunnelConnectMaxRetries; attempt++ { + err := vb.connectToTunnel(appDomain) + if err == nil { + if attempt > 1 { + cmdio.LogString(vb.ctx, "āœ… Connected to tunnel successfully!") + } + return nil + } + + lastErr = err + + // Check if context is cancelled + select { + case <-vb.ctx.Done(): + return vb.ctx.Err() + default: + } + + // Don't retry on the last attempt + if attempt == tunnelConnectMaxRetries { + break + } + + // Log retry attempt + cmdio.LogString(vb.ctx, fmt.Sprintf("ā³ Connection attempt %d/%d failed, retrying in %v...", attempt, tunnelConnectMaxRetries, backoff)) + log.Debugf(vb.ctx, "[vite_bridge] Connection error: %v", err) + + // Wait before retrying + select { + case <-time.After(backoff): + case <-vb.ctx.Done(): + return vb.ctx.Err() + } + + // Exponential backoff with cap + backoff = time.Duration(float64(backoff) * 1.5) + if backoff > tunnelConnectMaxBackoff { + backoff = tunnelConnectMaxBackoff + } + } + + return fmt.Errorf("failed to connect after %d attempts: %w", tunnelConnectMaxRetries, lastErr) +} + +func (vb *ViteBridge) connectToViteHMR() error { + dialer := websocket.Dialer{ + Subprotocols: []string{viteHMRProtocol}, + } + + conn, resp, err := dialer.Dial(fmt.Sprintf(localViteHMRURL, vb.port), nil) + if err != nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return fmt.Errorf("failed to connect to Vite HMR: %w", err) + } + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + + vb.hmrConn = conn + log.Infof(vb.ctx, "[vite_bridge] Connected to local Vite HMR WS") + return nil +} + +// tunnelKeepalive sends periodic pings to keep the connection alive. +// Remote servers often have 30-60s idle timeouts. +// The done channel is used to stop this goroutine on reconnect. +func (vb *ViteBridge) tunnelKeepalive(done <-chan struct{}) { + ticker := time.NewTicker(wsKeepaliveInterval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-vb.stopChan: + return + case <-ticker.C: + // Send ping through the write channel to avoid race conditions + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.PingMessage, + data: []byte{}, + priority: 0, // High priority to ensure keepalive + }: + log.Debugf(vb.ctx, "[vite_bridge] Sent keepalive ping") + case <-time.After(wsWriteTimeout): + log.Warnf(vb.ctx, "[vite_bridge] Failed to send keepalive ping (channel full)") + } + } + } +} + +// tunnelWriter handles all writes to the tunnel websocket in a single goroutine +// This eliminates mutex contention and ensures ordered delivery +func (vb *ViteBridge) tunnelWriter(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-vb.stopChan: + return nil + case msg := <-vb.tunnelWriteChan: + if err := vb.tunnelConn.WriteMessage(msg.messageType, msg.data); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Failed to write message: %v", err) + return fmt.Errorf("failed to write to tunnel: %w", err) + } + } + } +} + +func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-vb.stopChan: + return nil + default: + } + + _, message, err := vb.tunnelConn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) { + cmdio.LogString(vb.ctx, "šŸ”„ Tunnel closed, reconnecting...") + + appDomain, err := vb.GetAppDomain() + if err != nil { + return fmt.Errorf("failed to get app domain for reconnection: %w", err) + } + + if err := vb.connectToTunnelWithRetry(appDomain); err != nil { + return fmt.Errorf("failed to reconnect to tunnel: %w", err) + } + continue + } + return fmt.Errorf("tunnel connection error: %w", err) + } + + // Debug: Log raw message + log.Debugf(vb.ctx, "[vite_bridge] Raw message: %s", string(message)) + + var msg ViteBridgeMessage + if err := json.Unmarshal(message, &msg); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Failed to parse message: %v", err) + continue + } + + // Debug: Log all incoming message types + log.Debugf(vb.ctx, "[vite_bridge] Received message type: %s", msg.Type) + + if err := vb.handleMessage(&msg); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Error handling message: %v", err) + } + } +} + +func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { + switch msg.Type { + case "tunnel:ready": + vb.tunnelID = msg.TunnelID + log.Infof(vb.ctx, "[vite_bridge] Tunnel ID assigned: %s", vb.tunnelID) + return nil + + case "connection:request": + vb.connectionRequests <- msg + return nil + + case "fetch": + go func(fetchMsg ViteBridgeMessage) { + if err := vb.handleFetchRequest(&fetchMsg); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Error handling fetch request for %s: %v", fetchMsg.Path, err) + } + }(*msg) + return nil + + case "file:read": + // Handle file read requests in parallel like fetch requests + go func(fileReadMsg ViteBridgeMessage) { + if err := vb.handleFileReadRequest(&fileReadMsg); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Error handling file read request for %s: %v", fileReadMsg.Path, err) + } + }(*msg) + return nil + + case "hmr:message": + return vb.handleHMRMessage(msg) + + default: + log.Warnf(vb.ctx, "[vite_bridge] Unknown message type: %s", msg.Type) + return nil + } +} + +func (vb *ViteBridge) handleConnectionRequest(msg *ViteBridgeMessage) error { + cmdio.LogString(vb.ctx, "") + cmdio.LogString(vb.ctx, "šŸ”” Connection Request") + cmdio.LogString(vb.ctx, " User: "+msg.Viewer) + cmdio.LogString(vb.ctx, " Approve this connection? (y/n)") + + // Read from stdin with timeout to prevent indefinite blocking + inputChan := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + errChan <- err + return + } + inputChan <- input + }() + + var approved bool + select { + case input := <-inputChan: + approved = strings.ToLower(strings.TrimSpace(input)) == "y" + case err := <-errChan: + return fmt.Errorf("failed to read user input: %w", err) + case <-time.After(bridgeConnTimeout): + // Default to denying after timeout + cmdio.LogString(vb.ctx, "ā±ļø Timeout waiting for response, denying connection") + approved = false + } + + response := ViteBridgeMessage{ + Type: "connection:response", + RequestID: msg.RequestID, + Viewer: msg.Viewer, + Approved: approved, + } + + responseData, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("failed to marshal connection response: %w", err) + } + + // Send through channel instead of direct write + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 1, + }: + case <-time.After(wsWriteTimeout): + return errors.New("timeout sending connection response") + } + + if approved { + cmdio.LogString(vb.ctx, "āœ… Approved connection from "+msg.Viewer) + } else { + cmdio.LogString(vb.ctx, "āŒ Denied connection from "+msg.Viewer) + } + + return nil +} + +func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { + targetURL := fmt.Sprintf(localViteURL, vb.port) + msg.Path + log.Debugf(vb.ctx, "[vite_bridge] Fetch request: %s %s", msg.Method, msg.Path) + + req, err := http.NewRequestWithContext(vb.ctx, msg.Method, targetURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := vb.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch from Vite: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + log.Debugf(vb.ctx, "[vite_bridge] Fetch response: %s (status=%d, size=%d bytes)", msg.Path, resp.StatusCode, len(body)) + + headers := make(map[string]any, len(resp.Header)) + for key, values := range resp.Header { + if len(values) > 0 { + headers[key] = values[0] + } + } + + metadataResponse := ViteBridgeMessage{ + Type: "fetch:response:meta", + Path: msg.Path, + Status: resp.StatusCode, + Headers: headers, + RequestID: msg.RequestID, + } + + responseData, err := json.Marshal(metadataResponse) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 1, // Normal priority + }: + case <-time.After(bridgeFetchTimeout): + return errors.New("timeout sending fetch metadata") + } + + if len(body) > 0 { + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.BinaryMessage, + data: body, + priority: 1, // Normal priority + }: + case <-time.After(bridgeFetchTimeout): + return errors.New("timeout sending fetch body") + } + } + + return nil +} + +const ( + allowedBasePath = "config/queries" + allowedExtension = ".sql" +) + +func (vb *ViteBridge) handleFileReadRequest(msg *ViteBridgeMessage) error { + log.Debugf(vb.ctx, "[vite_bridge] File read request: %s", msg.Path) + + if err := validateFilePath(msg.Path); err != nil { + log.Warnf(vb.ctx, "[vite_bridge] File validation failed for %s: %v", msg.Path, err) + return vb.sendFileReadError(msg.RequestID, fmt.Sprintf("Invalid file path: %v", err)) + } + + content, err := os.ReadFile(msg.Path) + + response := ViteBridgeMessage{ + Type: "file:read:response", + RequestID: msg.RequestID, + } + + if err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Failed to read file %s: %v", msg.Path, err) + response.Error = err.Error() + } else { + log.Debugf(vb.ctx, "[vite_bridge] Read file %s (%d bytes)", msg.Path, len(content)) + response.Content = string(content) + } + + responseData, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("failed to marshal file read response: %w", err) + } + + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 1, + }: + case <-time.After(wsWriteTimeout): + return errors.New("timeout sending file read response") + } + + return nil +} + +func validateFilePath(requestedPath string) error { + // Clean the path to resolve any ../ or ./ components + cleanPath := filepath.Clean(requestedPath) + + // Get absolute path + absPath, err := filepath.Abs(cleanPath) + if err != nil { + return fmt.Errorf("failed to resolve absolute path: %w", err) + } + + // Get the working directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Construct the allowed base directory (absolute path) + allowedDir := filepath.Join(cwd, allowedBasePath) + + // Ensure the resolved path is within the allowed directory + // Add trailing separator to prevent prefix attacks (e.g., queries-malicious/) + allowedDirWithSep := allowedDir + string(filepath.Separator) + if absPath != allowedDir && !strings.HasPrefix(absPath, allowedDirWithSep) { + return fmt.Errorf("path %s is outside allowed directory %s", absPath, allowedBasePath) + } + + // Ensure the file has the correct extension + if filepath.Ext(absPath) != allowedExtension { + return fmt.Errorf("only %s files are allowed, got: %s", allowedExtension, filepath.Ext(absPath)) + } + + // Additional check: no hidden files + if strings.HasPrefix(filepath.Base(absPath), ".") { + return errors.New("hidden files are not allowed") + } + + return nil +} + +// Helper to send error response +func (vb *ViteBridge) sendFileReadError(requestID, errorMsg string) error { + response := ViteBridgeMessage{ + Type: "file:read:response", + RequestID: requestID, + Error: errorMsg, + } + + responseData, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("failed to marshal error response: %w", err) + } + + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 1, + }: + case <-time.After(wsWriteTimeout): + return errors.New("timeout sending file read error") + } + + return nil +} + +func (vb *ViteBridge) handleHMRMessage(msg *ViteBridgeMessage) error { + log.Debugf(vb.ctx, "[vite_bridge] HMR message received: %s", msg.Body) + + response := ViteBridgeMessage{ + Type: "hmr:client", + Body: msg.Body, + } + + responseData, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("failed to marshal HMR message: %w", err) + } + + // Send HMR with HIGH priority so it doesn't get blocked by fetch requests + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 0, // HIGH PRIORITY for HMR! + }: + case <-time.After(wsWriteTimeout): + return errors.New("timeout sending HMR message") + } + + return nil +} + +func (vb *ViteBridge) handleViteHMRMessages(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-vb.stopChan: + return nil + default: + } + + _, message, err := vb.hmrConn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Infof(vb.ctx, "[vite_bridge] Vite HMR connection closed, reconnecting...") + time.Sleep(time.Second) + if err := vb.connectToViteHMR(); err != nil { + return fmt.Errorf("failed to reconnect to Vite HMR: %w", err) + } + continue + } + return err + } + + response := ViteBridgeMessage{ + Type: "hmr:message", + Body: string(message), + } + + responseData, err := json.Marshal(response) + if err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Failed to marshal Vite HMR message: %v", err) + continue + } + + select { + case vb.tunnelWriteChan <- prioritizedMessage{ + messageType: websocket.TextMessage, + data: responseData, + priority: 0, + }: + case <-time.After(wsWriteTimeout): + log.Errorf(vb.ctx, "[vite_bridge] Timeout sending Vite HMR message") + } + } +} + +func (vb *ViteBridge) Start() error { + appDomain, err := vb.GetAppDomain() + if err != nil { + return fmt.Errorf("failed to get app domain: %w", err) + } + + // Use retry logic for initial connection (app may not be ready yet) + if err := vb.connectToTunnelWithRetry(appDomain); err != nil { + return err + } + + readyChan := make(chan error, 1) + go func() { + for vb.tunnelID == "" { + _, message, err := vb.tunnelConn.ReadMessage() + if err != nil { + readyChan <- err + return + } + + var msg ViteBridgeMessage + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + if msg.Type == "tunnel:ready" { + vb.tunnelID = msg.TunnelID + log.Infof(vb.ctx, "[vite_bridge] Tunnel ID assigned: %s", vb.tunnelID) + readyChan <- nil + return + } + } + }() + + select { + case err := <-readyChan: + if err != nil { + return fmt.Errorf("failed waiting for tunnel ready: %w", err) + } + case <-time.After(bridgeTunnelReadyTimeout): + return errors.New("timeout waiting for tunnel ready") + } + + if err := vb.connectToViteHMR(); err != nil { + return err + } + + cmdio.LogString(vb.ctx, fmt.Sprintf("\n🌐 App URL:\n%s?dev=true\n", appDomain.String())) + cmdio.LogString(vb.ctx, fmt.Sprintf("\nšŸ”— Shareable URL:\n%s?dev=%s\n", appDomain.String(), vb.tunnelID)) + + g, gCtx := errgroup.WithContext(vb.ctx) + + // Start dedicated tunnel writer goroutine + g.Go(func() error { + if err := vb.tunnelWriter(gCtx); err != nil { + return fmt.Errorf("tunnel writer error: %w", err) + } + return nil + }) + + // Connection request handler - not in errgroup to avoid blocking other handlers + go func() { + for { + select { + case msg := <-vb.connectionRequests: + if err := vb.handleConnectionRequest(msg); err != nil { + log.Errorf(vb.ctx, "[vite_bridge] Error handling connection request: %v", err) + } + case <-gCtx.Done(): + return + case <-vb.stopChan: + return + } + } + }() + + g.Go(func() error { + if err := vb.handleTunnelMessages(gCtx); err != nil { + return fmt.Errorf("tunnel message handler error: %w", err) + } + return nil + }) + + g.Go(func() error { + if err := vb.handleViteHMRMessages(gCtx); err != nil { + return fmt.Errorf("vite HMR message handler error: %w", err) + } + return nil + }) + + <-gCtx.Done() + vb.Stop() + return g.Wait() +} + +func (vb *ViteBridge) Stop() { + vb.stopOnce.Do(func() { + close(vb.stopChan) + + if vb.tunnelConn != nil { + _ = vb.tunnelConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + vb.tunnelConn.Close() + } + + if vb.hmrConn != nil { + _ = vb.hmrConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + vb.hmrConn.Close() + } + }) +} diff --git a/experimental/dev/cmd/app/vite_bridge_test.go b/experimental/dev/cmd/app/vite_bridge_test.go new file mode 100644 index 0000000000..45f758563c --- /dev/null +++ b/experimental/dev/cmd/app/vite_bridge_test.go @@ -0,0 +1,370 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateFilePath(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldWd) }() + + // Change to temp directory + err = os.Chdir(tmpDir) + require.NoError(t, err) + + // Create the allowed directory + queriesDir := filepath.Join(tmpDir, "config", "queries") + err = os.MkdirAll(queriesDir, 0o755) + require.NoError(t, err) + + // Create a valid test file + validFile := filepath.Join(queriesDir, "test.sql") + err = os.WriteFile(validFile, []byte("SELECT * FROM table"), 0o644) + require.NoError(t, err) + + tests := []struct { + name string + path string + expectError bool + errorMsg string + }{ + { + name: "valid file path", + path: "config/queries/test.sql", + expectError: false, + }, + { + name: "path outside allowed directory", + path: "../../etc/passwd", + expectError: true, + errorMsg: "outside allowed directory", + }, + { + name: "wrong file extension", + path: "config/queries/test.txt", + expectError: true, + errorMsg: "only .sql files are allowed", + }, + { + name: "hidden file", + path: "config/queries/.hidden.sql", + expectError: true, + errorMsg: "hidden files are not allowed", + }, + { + name: "path traversal attempt", + path: "config/queries/../../../etc/passwd", + expectError: true, + errorMsg: "outside allowed directory", + }, + { + name: "prefix attack - similar directory name", + path: "config/queries-malicious/test.sql", + expectError: true, + errorMsg: "outside allowed directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateFilePath(tt.path) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestViteBridgeMessageSerialization(t *testing.T) { + tests := []struct { + name string + msg ViteBridgeMessage + }{ + { + name: "tunnel ready message", + msg: ViteBridgeMessage{ + Type: "tunnel:ready", + TunnelID: "test-tunnel-123", + }, + }, + { + name: "fetch request message", + msg: ViteBridgeMessage{ + Type: "fetch", + Path: "/src/components/ui/card.tsx", + Method: "GET", + RequestID: "req-123", + }, + }, + { + name: "connection request message", + msg: ViteBridgeMessage{ + Type: "connection:request", + Viewer: "user@example.com", + RequestID: "req-456", + }, + }, + { + name: "fetch response with headers", + msg: ViteBridgeMessage{ + Type: "fetch:response:meta", + Status: 200, + Headers: map[string]any{ + "Content-Type": "application/json", + }, + RequestID: "req-789", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.msg) + require.NoError(t, err) + + var decoded ViteBridgeMessage + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, tt.msg.Type, decoded.Type) + assert.Equal(t, tt.msg.TunnelID, decoded.TunnelID) + assert.Equal(t, tt.msg.Path, decoded.Path) + assert.Equal(t, tt.msg.Method, decoded.Method) + assert.Equal(t, tt.msg.RequestID, decoded.RequestID) + }) + } +} + +func TestViteBridgeHandleMessage(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + + w := &databricks.WorkspaceClient{} + + vb := NewViteBridge(ctx, w, "test-app", 5173) + + tests := []struct { + name string + msg *ViteBridgeMessage + expectError bool + }{ + { + name: "tunnel ready message", + msg: &ViteBridgeMessage{ + Type: "tunnel:ready", + TunnelID: "tunnel-123", + }, + expectError: false, + }, + { + name: "unknown message type", + msg: &ViteBridgeMessage{ + Type: "unknown:type", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := vb.handleMessage(tt.msg) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + if tt.msg.Type == "tunnel:ready" { + assert.Equal(t, tt.msg.TunnelID, vb.tunnelID) + } + }) + } +} + +func TestViteBridgeHandleFileReadRequest(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldWd) }() + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + queriesDir := filepath.Join(tmpDir, "config", "queries") + err = os.MkdirAll(queriesDir, 0o755) + require.NoError(t, err) + + testContent := "SELECT * FROM users WHERE id = 1" + testFile := filepath.Join(queriesDir, "test_query.sql") + err = os.WriteFile(testFile, []byte(testContent), 0o644) + require.NoError(t, err) + + t.Run("successful file read", func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + w := &databricks.WorkspaceClient{} + + // Create a mock tunnel connection using httptest + var lastMessage []byte + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("failed to upgrade: %v", err) + return + } + defer conn.Close() + + // Read the message sent by handleFileReadRequest + _, message, err := conn.ReadMessage() + if err != nil { + t.Errorf("failed to read message: %v", err) + return + } + lastMessage = message + })) + defer server.Close() + + // Connect to the mock server + wsURL := "ws" + server.URL[4:] + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer resp.Body.Close() + defer conn.Close() + + vb := NewViteBridge(ctx, w, "test-app", 5173) + vb.tunnelConn = conn + + go func() { _ = vb.tunnelWriter(ctx) }() + + msg := &ViteBridgeMessage{ + Type: "file:read", + Path: "config/queries/test_query.sql", + RequestID: "req-123", + } + + err = vb.handleFileReadRequest(msg) + require.NoError(t, err) + + // Give the message time to be sent + time.Sleep(100 * time.Millisecond) + + // Parse the response + var response ViteBridgeMessage + err = json.Unmarshal(lastMessage, &response) + require.NoError(t, err) + + assert.Equal(t, "file:read:response", response.Type) + assert.Equal(t, "req-123", response.RequestID) + assert.Equal(t, testContent, response.Content) + assert.Empty(t, response.Error) + }) + + t.Run("file not found", func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + w := &databricks.WorkspaceClient{} + + var lastMessage []byte + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("failed to upgrade: %v", err) + return + } + defer conn.Close() + + _, message, err := conn.ReadMessage() + if err != nil { + t.Errorf("failed to read message: %v", err) + return + } + lastMessage = message + })) + defer server.Close() + + wsURL := "ws" + server.URL[4:] + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer resp.Body.Close() + defer conn.Close() + + vb := NewViteBridge(ctx, w, "test-app", 5173) + vb.tunnelConn = conn + + go func() { _ = vb.tunnelWriter(ctx) }() + + msg := &ViteBridgeMessage{ + Type: "file:read", + Path: "config/queries/nonexistent.sql", + RequestID: "req-456", + } + + err = vb.handleFileReadRequest(msg) + require.NoError(t, err) + + // Give the message time to be sent + time.Sleep(100 * time.Millisecond) + + var response ViteBridgeMessage + err = json.Unmarshal(lastMessage, &response) + require.NoError(t, err) + + assert.Equal(t, "file:read:response", response.Type) + assert.Equal(t, "req-456", response.RequestID) + assert.NotEmpty(t, response.Error) + }) +} + +func TestViteBridgeStop(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + w := &databricks.WorkspaceClient{} + + vb := NewViteBridge(ctx, w, "test-app", 5173) + + // Call Stop multiple times to ensure it's idempotent + vb.Stop() + vb.Stop() + vb.Stop() + + // Verify stopChan is closed + select { + case <-vb.stopChan: + // Channel is closed, this is expected + default: + t.Error("stopChan should be closed after Stop()") + } +} + +func TestNewViteBridge(t *testing.T) { + ctx := context.Background() + w := &databricks.WorkspaceClient{} + appName := "test-app" + + vb := NewViteBridge(ctx, w, appName, 5173) + + assert.NotNil(t, vb) + assert.Equal(t, appName, vb.appName) + assert.NotNil(t, vb.httpClient) + assert.NotNil(t, vb.stopChan) + assert.NotNil(t, vb.connectionRequests) + assert.Equal(t, 10, cap(vb.connectionRequests)) +} diff --git a/experimental/dev/cmd/dev.go b/experimental/dev/cmd/dev.go new file mode 100644 index 0000000000..27679e71fc --- /dev/null +++ b/experimental/dev/cmd/dev.go @@ -0,0 +1,21 @@ +package dev + +import ( + "github.com/databricks/cli/experimental/dev/cmd/app" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "dev", + Short: "Development tools for Databricks applications", + Long: `Development tools for Databricks applications. + +Provides commands for creating, developing, and deploying full-stack +Databricks applications.`, + } + + cmd.AddCommand(app.New()) + + return cmd +} diff --git a/go.mod b/go.mod index dd1a073f73..3a4c15f796 100644 --- a/go.mod +++ b/go.mod @@ -43,15 +43,32 @@ require ( // Dependencies for experimental MCP commands require github.com/google/jsonschema-go v0.4.2 // MIT +require ( + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 +) + require ( cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.4 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -62,9 +79,18 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zclconf/go-cty v1.16.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect diff --git a/go.sum b/go.sum index 8be5ce6c28..397fcc914b 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -16,8 +18,44 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -27,6 +65,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/databricks/databricks-sdk-go v0.96.0 h1:tpR3GSwkM3Vd6P9KfYEXAJiKZ1KLJ2T2+J3tF8jxlEk= @@ -34,8 +74,12 @@ github.com/databricks/databricks-sdk-go v0.96.0/go.mod h1:hWoHnHbNLjPKiTm5K/7bcI github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -101,6 +145,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -111,6 +157,18 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nwidger/jsoncolor v0.3.2 h1:rVJJlwAWDJShnbTYOQ5RM7yTA20INyKXlJ/fg4JMhHQ= github.com/nwidger/jsoncolor v0.3.2/go.mod h1:Cs34umxLbJvgBMnVNVqhji9BhoT/N/KinHqZptQ7cf4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= @@ -121,6 +179,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -143,6 +204,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zclconf/go-cty v1.16.4 h1:QGXaag7/7dCzb+odlGrgr+YmYZFaOCMW6DEpS+UD1eE= github.com/zclconf/go-cty v1.16.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -176,6 +239,7 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From dafa2ed8b2f7c13e7727bbb5bc80194043bb1564 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 14 Jan 2026 14:33:15 +0100 Subject: [PATCH 02/13] chore: fixup --- experimental/dev/cmd/app/features.go | 119 ++++++++ experimental/dev/cmd/app/features_test.go | 203 +++++++++++++ experimental/dev/cmd/app/init.go | 329 +++++++++++++++++----- experimental/dev/cmd/app/init_test.go | 65 +++++ experimental/dev/cmd/app/prompt.go | 130 ++++++++- 5 files changed, 762 insertions(+), 84 deletions(-) diff --git a/experimental/dev/cmd/app/features.go b/experimental/dev/cmd/app/features.go index 6e03b19de7..768c8f167b 100644 --- a/experimental/dev/cmd/app/features.go +++ b/experimental/dev/cmd/app/features.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" ) @@ -75,6 +76,124 @@ var featureByID = func() map[string]Feature { return m }() +// featureByPluginImport maps plugin import names to features. +var featureByPluginImport = func() map[string]Feature { + m := make(map[string]Feature, len(AvailableFeatures)) + for _, f := range AvailableFeatures { + if f.PluginImport != "" { + m[f.PluginImport] = f + } + } + return m +}() + +// pluginPattern matches plugin function calls dynamically built from AvailableFeatures. +// Matches patterns like: analytics(), genie(), oauth(), etc. +var pluginPattern = func() *regexp.Regexp { + var plugins []string + for _, f := range AvailableFeatures { + if f.PluginImport != "" { + plugins = append(plugins, regexp.QuoteMeta(f.PluginImport)) + } + } + if len(plugins) == 0 { + // Fallback pattern that matches nothing + return regexp.MustCompile(`$^`) + } + // Build pattern: \b(plugin1|plugin2|plugin3)\s*\( + pattern := `\b(` + strings.Join(plugins, "|") + `)\s*\(` + return regexp.MustCompile(pattern) +}() + +// serverFilePaths lists common locations for the server entry file. +var serverFilePaths = []string{ + "src/server/index.ts", + "src/server/index.tsx", + "src/server.ts", + "server/index.ts", + "server/server.ts", + "server.ts", +} + +// TODO: We should come to an agreement if we want to do it like this, +// or maybe we should have an appkit.json manifest file in each project. +func DetectPluginsFromServer(templateDir string) ([]string, error) { + var content []byte + + for _, p := range serverFilePaths { + fullPath := filepath.Join(templateDir, p) + data, err := os.ReadFile(fullPath) + if err == nil { + content = data + break + } + } + + if content == nil { + return nil, nil // No server file found + } + + matches := pluginPattern.FindAllStringSubmatch(string(content), -1) + seen := make(map[string]bool) + var plugins []string + + for _, m := range matches { + plugin := m[1] + if !seen[plugin] { + seen[plugin] = true + plugins = append(plugins, plugin) + } + } + + return plugins, nil +} + +// GetPluginDependencies returns all dependencies required by the given plugin names. +func GetPluginDependencies(pluginNames []string) []FeatureDependency { + seen := make(map[string]bool) + var deps []FeatureDependency + + for _, plugin := range pluginNames { + feature, ok := featureByPluginImport[plugin] + if !ok { + continue + } + for _, dep := range feature.Dependencies { + if !seen[dep.ID] { + seen[dep.ID] = true + deps = append(deps, dep) + } + } + } + + return deps +} + +// MapPluginsToFeatures maps plugin import names to feature IDs. +// This is used to convert detected plugins (e.g., "analytics") to feature IDs +// so that ApplyFeatures can properly retain feature-specific files. +func MapPluginsToFeatures(pluginNames []string) []string { + seen := make(map[string]bool) + var features []string + + for _, plugin := range pluginNames { + feature, ok := featureByPluginImport[plugin] + if ok && !seen[feature.ID] { + seen[feature.ID] = true + features = append(features, feature.ID) + } + } + + return features +} + +// HasFeaturesDirectory checks if the template uses the feature-fragment system. +func HasFeaturesDirectory(templateDir string) bool { + featuresDir := filepath.Join(templateDir, "features") + info, err := os.Stat(featuresDir) + return err == nil && info.IsDir() +} + // ValidateFeatureIDs checks that all provided feature IDs are valid. // Returns an error if any feature ID is unknown. func ValidateFeatureIDs(featureIDs []string) error { diff --git a/experimental/dev/cmd/app/features_test.go b/experimental/dev/cmd/app/features_test.go index 18d34fe9f7..a3aaf3f0ab 100644 --- a/experimental/dev/cmd/app/features_test.go +++ b/experimental/dev/cmd/app/features_test.go @@ -1,6 +1,8 @@ package app import ( + "fmt" + "os" "testing" "github.com/stretchr/testify/assert" @@ -248,3 +250,204 @@ func TestCollectResourceFiles(t *testing.T) { }) } } + +func TestDetectPluginsFromServer(t *testing.T) { + tests := []struct { + name string + serverContent string + expectedPlugins []string + }{ + { + name: "analytics plugin", + serverContent: `import { createApp, server, analytics } from '@databricks/appkit'; +createApp({ + plugins: [ + server(), + analytics(), + ], +}).catch(console.error);`, + expectedPlugins: []string{"analytics"}, + }, + { + name: "analytics with other plugins not in AvailableFeatures", + serverContent: `import { createApp, server, analytics, genie } from '@databricks/appkit'; +createApp({ + plugins: [ + server(), + analytics(), + genie(), + ], +}).catch(console.error);`, + expectedPlugins: []string{"analytics"}, // Only analytics is detected since genie is not in AvailableFeatures + }, + { + name: "no recognized plugins", + serverContent: `import { createApp, server } from '@databricks/appkit';`, + expectedPlugins: nil, + }, + { + name: "plugin not in AvailableFeatures", + serverContent: `createApp({ + plugins: [oauth()], +});`, + expectedPlugins: nil, // oauth is not in AvailableFeatures, so not detected + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp dir with server file + tempDir := t.TempDir() + serverDir := tempDir + "/src/server" + require.NoError(t, os.MkdirAll(serverDir, 0o755)) + require.NoError(t, os.WriteFile(serverDir+"/index.ts", []byte(tt.serverContent), 0o644)) + + plugins, err := DetectPluginsFromServer(tempDir) + require.NoError(t, err) + assert.Equal(t, tt.expectedPlugins, plugins) + }) + } +} + +func TestDetectPluginsFromServerAlternatePath(t *testing.T) { + // Test server/server.ts path (common in some templates) + tempDir := t.TempDir() + serverDir := tempDir + "/server" + require.NoError(t, os.MkdirAll(serverDir, 0o755)) + + serverContent := `import { createApp, server, analytics } from '@databricks/appkit'; +createApp({ + plugins: [ + server(), + analytics(), + ], +}).catch(console.error);` + + require.NoError(t, os.WriteFile(serverDir+"/server.ts", []byte(serverContent), 0o644)) + + plugins, err := DetectPluginsFromServer(tempDir) + require.NoError(t, err) + assert.Equal(t, []string{"analytics"}, plugins) +} + +func TestDetectPluginsFromServerNoFile(t *testing.T) { + tempDir := t.TempDir() + plugins, err := DetectPluginsFromServer(tempDir) + require.NoError(t, err) + assert.Nil(t, plugins) +} + +func TestGetPluginDependencies(t *testing.T) { + tests := []struct { + name string + pluginNames []string + expectedDeps []string + }{ + { + name: "analytics plugin", + pluginNames: []string{"analytics"}, + expectedDeps: []string{"sql_warehouse_id"}, + }, + { + name: "unknown plugin", + pluginNames: []string{"server"}, + expectedDeps: nil, + }, + { + name: "empty plugins", + pluginNames: []string{}, + expectedDeps: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := GetPluginDependencies(tt.pluginNames) + if tt.expectedDeps == nil { + assert.Empty(t, deps) + } else { + assert.Len(t, deps, len(tt.expectedDeps)) + for i, dep := range deps { + assert.Equal(t, tt.expectedDeps[i], dep.ID) + } + } + }) + } +} + +func TestHasFeaturesDirectory(t *testing.T) { + // Test with features directory + tempDir := t.TempDir() + require.NoError(t, os.MkdirAll(tempDir+"/features", 0o755)) + assert.True(t, HasFeaturesDirectory(tempDir)) + + // Test without features directory + tempDir2 := t.TempDir() + assert.False(t, HasFeaturesDirectory(tempDir2)) +} + +func TestMapPluginsToFeatures(t *testing.T) { + tests := []struct { + name string + pluginNames []string + expectedFeatures []string + }{ + { + name: "analytics plugin maps to analytics feature", + pluginNames: []string{"analytics"}, + expectedFeatures: []string{"analytics"}, + }, + { + name: "unknown plugin", + pluginNames: []string{"server", "unknown"}, + expectedFeatures: nil, + }, + { + name: "empty plugins", + pluginNames: []string{}, + expectedFeatures: nil, + }, + { + name: "duplicate plugins", + pluginNames: []string{"analytics", "analytics"}, + expectedFeatures: []string{"analytics"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + features := MapPluginsToFeatures(tt.pluginNames) + if tt.expectedFeatures == nil { + assert.Empty(t, features) + } else { + assert.Equal(t, tt.expectedFeatures, features) + } + }) + } +} + +func TestPluginPatternGeneration(t *testing.T) { + // Test that the plugin pattern is dynamically generated from AvailableFeatures + // This ensures new features with PluginImport are automatically detected + + // Get all plugin imports from AvailableFeatures + var expectedPlugins []string + for _, f := range AvailableFeatures { + if f.PluginImport != "" { + expectedPlugins = append(expectedPlugins, f.PluginImport) + } + } + + // Test that each plugin is matched by the pattern + for _, plugin := range expectedPlugins { + testCode := fmt.Sprintf("plugins: [%s()]", plugin) + matches := pluginPattern.FindAllStringSubmatch(testCode, -1) + assert.NotEmpty(t, matches, "Pattern should match plugin: %s", plugin) + assert.Equal(t, plugin, matches[0][1], "Captured group should be plugin name: %s", plugin) + } + + // Test that non-plugin function calls are not matched + testCode := "const x = someOtherFunction()" + matches := pluginPattern.FindAllStringSubmatch(testCode, -1) + assert.Empty(t, matches, "Pattern should not match non-plugin functions") +} diff --git a/experimental/dev/cmd/app/init.go b/experimental/dev/cmd/app/init.go index 2fcbcc2ce9..a186826517 100644 --- a/experimental/dev/cmd/app/init.go +++ b/experimental/dev/cmd/app/init.go @@ -11,9 +11,11 @@ import ( "strings" "text/template" + "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" "github.com/spf13/cobra" ) @@ -138,6 +140,116 @@ type featureFragments struct { DotEnvExample string } +// parseDeployAndRunFlags parses the deploy and run flag values into typed values. +func parseDeployAndRunFlags(deploy bool, run string) (bool, RunMode, error) { + var runMode RunMode + switch run { + case "dev": + runMode = RunModeDev + case "dev-remote": + runMode = RunModeDevRemote + case "", "none": + runMode = RunModeNone + default: + return false, RunModeNone, fmt.Errorf("invalid --run value: %q (must be none, dev, or dev-remote)", run) + } + return deploy, runMode, nil +} + +// promptForFeaturesAndDeps prompts for features and their dependencies. +// Used when the template uses the feature-fragment system. +func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { + config := &CreateProjectConfig{ + Dependencies: make(map[string]string), + Features: preSelectedFeatures, + } + theme := appkitTheme() + + // Step 1: Feature selection (skip if features already provided via flag) + if len(config.Features) == 0 && len(AvailableFeatures) > 0 { + options := make([]huh.Option[string], 0, len(AvailableFeatures)) + for _, f := range AvailableFeatures { + label := f.Name + " - " + f.Description + options = append(options, huh.NewOption(label, f.ID)) + } + + err := huh.NewMultiSelect[string](). + Title("Select features"). + Description("space to toggle, enter to confirm"). + Options(options...). + Value(&config.Features). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + } + + // Step 2: Prompt for feature dependencies + deps := CollectDependencies(config.Features) + for _, dep := range deps { + // Special handling for SQL warehouse - show picker instead of text input + if dep.ID == "sql_warehouse_id" { + warehouseID, err := PromptForWarehouse(ctx) + if err != nil { + return nil, err + } + config.Dependencies[dep.ID] = warehouseID + continue + } + + var value string + description := dep.Description + if !dep.Required { + description += " (optional)" + } + + input := huh.NewInput(). + Title(dep.Title). + Description(description). + Placeholder(dep.Placeholder). + Value(&value) + + if dep.Required { + input = input.Validate(func(s string) error { + if s == "" { + return errors.New("this field is required") + } + return nil + }) + } + + if err := input.WithTheme(theme).Run(); err != nil { + return nil, err + } + config.Dependencies[dep.ID] = value + } + + // Step 3: Description + config.Description = DefaultAppDescription + err := huh.NewInput(). + Title("Description"). + Placeholder(DefaultAppDescription). + Value(&config.Description). + WithTheme(theme). + Run() + if err != nil { + return nil, err + } + + if config.Description == "" { + config.Description = DefaultAppDescription + } + + // Step 4: Deploy and run options + config.Deploy, config.RunMode, err = PromptForDeployAndRun() + if err != nil { + return nil, err + } + + return config, nil +} + // loadFeatureFragments reads and aggregates resource fragments for selected features. // templateDir is the path to the template directory (containing the "features" subdirectory). func loadFeatureFragments(templateDir string, featureIDs []string, vars templateVars) (*featureFragments, error) { @@ -310,71 +422,32 @@ func runCreate(ctx context.Context, opts createOptions) error { var dependencies map[string]string var shouldDeploy bool var runMode RunMode = RunModeNone + isInteractive := cmdio.IsPromptSupported(ctx) // Use features from flags if provided if len(opts.features) > 0 { selectedFeatures = opts.features } - // Non-interactive mode: name provided via flag - if opts.name != "" { - // Build flag values map for dependency validation - flagValues := map[string]string{ - "warehouse-id": opts.warehouseID, - } - - // Validate that required dependencies are provided via flags - if len(selectedFeatures) > 0 { - if err := ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { - return err - } - } - - // Map flag values to dependencies - dependencies = make(map[string]string) - if opts.warehouseID != "" { - dependencies["sql_warehouse_id"] = opts.warehouseID - } + // Resolve template path (supports local paths and GitHub URLs) + templateSrc := opts.templatePath + if templateSrc == "" { + templateSrc = os.Getenv(templatePathEnvVar) + } + if templateSrc == "" { + return errors.New("template path required: set DATABRICKS_APPKIT_TEMPLATE_PATH or use --template flag") + } - // Use deploy and run flags - shouldDeploy = opts.deploy - switch opts.run { - case "dev": - runMode = RunModeDev - case "dev-remote": - runMode = RunModeDevRemote - case "", "none": - runMode = RunModeNone - default: - return fmt.Errorf("invalid --run value: %q (must be none, dev, or dev-remote)", opts.run) - } - } else { - // Interactive mode: prompt for everything - if !cmdio.IsPromptSupported(ctx) { + // Step 1: Get project name first (needed before we can check destination) + if opts.name == "" { + if !isInteractive { return errors.New("--name is required in non-interactive mode") } - - // Pass pre-selected features to skip that prompt if already provided via flag - config, err := PromptForProjectConfig(ctx, selectedFeatures) + name, err := PromptForProjectName() if err != nil { return err } - opts.name = config.ProjectName - if config.Description != "" { - opts.description = config.Description - } - // Use features from prompt if not already set via flag - if len(selectedFeatures) == 0 { - selectedFeatures = config.Features - } - dependencies = config.Dependencies - shouldDeploy = config.Deploy - runMode = config.RunMode - - // Get warehouse from dependencies if provided - if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { - opts.warehouseID = wh - } + opts.name = name } // Validate project name @@ -382,26 +455,7 @@ func runCreate(ctx context.Context, opts createOptions) error { return err } - // Validate feature IDs - if err := ValidateFeatureIDs(selectedFeatures); err != nil { - return err - } - - // Set defaults - if opts.description == "" { - opts.description = DefaultAppDescription - } - - // Resolve template path (supports local paths and GitHub URLs) - templateSrc := opts.templatePath - if templateSrc == "" { - templateSrc = os.Getenv(templatePathEnvVar) - } - if templateSrc == "" { - return errors.New("template path required: set DATABRICKS_APPKIT_TEMPLATE_PATH or use --template flag") - } - - // Resolve template (handles GitHub URLs by cloning) + // Step 2: Resolve template (handles GitHub URLs by cloning) resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, opts.branch) if err != nil { return err @@ -420,6 +474,119 @@ func runCreate(ctx context.Context, opts createOptions) error { } } + // Step 3: Determine template type and gather configuration + usesFeatureFragments := HasFeaturesDirectory(templateDir) + + if usesFeatureFragments { + // Feature-fragment template: prompt for features and their dependencies + if isInteractive && len(selectedFeatures) == 0 { + // Need to prompt for features (but we already have the name) + config, err := promptForFeaturesAndDeps(ctx, selectedFeatures) + if err != nil { + return err + } + selectedFeatures = config.Features + dependencies = config.Dependencies + if config.Description != "" { + opts.description = config.Description + } + shouldDeploy = config.Deploy + runMode = config.RunMode + + // Get warehouse from dependencies if provided + if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { + opts.warehouseID = wh + } + } else { + // Non-interactive or features provided via flag + flagValues := map[string]string{ + "warehouse-id": opts.warehouseID, + } + if len(selectedFeatures) > 0 { + if err := ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { + return err + } + } + dependencies = make(map[string]string) + if opts.warehouseID != "" { + dependencies["sql_warehouse_id"] = opts.warehouseID + } + var err error + shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) + if err != nil { + return err + } + } + + // Validate feature IDs + if err := ValidateFeatureIDs(selectedFeatures); err != nil { + return err + } + } else { + // Pre-assembled template: detect plugins and prompt for their dependencies + detectedPlugins, err := DetectPluginsFromServer(templateDir) + if err != nil { + return fmt.Errorf("failed to detect plugins: %w", err) + } + + log.Debugf(ctx, "Detected plugins: %v", detectedPlugins) + + // Map detected plugins to feature IDs for ApplyFeatures + selectedFeatures = MapPluginsToFeatures(detectedPlugins) + log.Debugf(ctx, "Mapped to features: %v", selectedFeatures) + + pluginDeps := GetPluginDependencies(detectedPlugins) + + log.Debugf(ctx, "Plugin dependencies: %d", len(pluginDeps)) + + if isInteractive && len(pluginDeps) > 0 { + // Prompt for plugin dependencies + dependencies, err = PromptForPluginDependencies(ctx, pluginDeps) + if err != nil { + return err + } + if wh, ok := dependencies["sql_warehouse_id"]; ok && wh != "" { + opts.warehouseID = wh + } + } else { + // Non-interactive: check flags + dependencies = make(map[string]string) + if opts.warehouseID != "" { + dependencies["sql_warehouse_id"] = opts.warehouseID + } + + // Validate required dependencies are provided + for _, dep := range pluginDeps { + if dep.Required { + if _, ok := dependencies[dep.ID]; !ok { + return fmt.Errorf("missing required flag --%s for detected plugin", dep.FlagName) + } + } + } + } + + // Prompt for description and post-creation actions + if isInteractive { + if opts.description == "" { + opts.description = DefaultAppDescription + } + var deployVal bool + var runVal RunMode + deployVal, runVal, err = PromptForDeployAndRun() + if err != nil { + return err + } + shouldDeploy = deployVal + runMode = runVal + } else { + var err error + shouldDeploy, runMode, err = parseDeployAndRunFlags(opts.deploy, opts.run) + if err != nil { + return err + } + } + } + // Determine output directory destDir := opts.name if opts.outputDir != "" { @@ -441,6 +608,11 @@ func runCreate(ctx context.Context, opts createOptions) error { } }() + // Set description default + if opts.description == "" { + opts.description = DefaultAppDescription + } + // Get workspace host and profile from context workspaceHost := "" profile := "" @@ -644,6 +816,8 @@ func copyTemplate(src, dest string, vars templateVars) (int, error) { srcProjectDir = src } + log.Debugf(context.Background(), "Copying template from: %s", srcProjectDir) + // Files and directories to skip skipFiles := map[string]bool{ "CLAUDE.md": true, @@ -664,11 +838,13 @@ func copyTemplate(src, dest string, vars templateVars) (int, error) { // Skip certain files if skipFiles[baseName] { + log.Debugf(context.Background(), "Skipping file: %s", baseName) return nil } // Skip certain directories if info.IsDir() && skipDirs[baseName] { + log.Debugf(context.Background(), "Skipping directory: %s", baseName) return filepath.SkipDir } @@ -693,9 +869,12 @@ func copyTemplate(src, dest string, vars templateVars) (int, error) { destPath := filepath.Join(dest, relPath) if info.IsDir() { + log.Debugf(context.Background(), "Creating directory: %s", relPath) return os.MkdirAll(destPath, info.Mode()) } + log.Debugf(context.Background(), "Copying file: %s", relPath) + // Read file content content, err := os.ReadFile(srcPath) if err != nil { @@ -735,6 +914,10 @@ func copyTemplate(src, dest string, vars templateVars) (int, error) { fileCount++ return nil }) + if err != nil { + log.Debugf(context.Background(), "Error during template copy: %v", err) + } + log.Debugf(context.Background(), "Copied %d files", fileCount) return fileCount, err } diff --git a/experimental/dev/cmd/app/init_test.go b/experimental/dev/cmd/app/init_test.go index 138b92f908..a7746e7a35 100644 --- a/experimental/dev/cmd/app/init_test.go +++ b/experimental/dev/cmd/app/init_test.go @@ -236,3 +236,68 @@ func TestSubstituteVarsNoPlugins(t *testing.T) { }) } } + +func TestParseDeployAndRunFlags(t *testing.T) { + tests := []struct { + name string + deploy bool + run string + wantDeploy bool + wantRunMode RunMode + wantErr bool + }{ + { + name: "deploy true, run none", + deploy: true, + run: "none", + wantDeploy: true, + wantRunMode: RunModeNone, + wantErr: false, + }, + { + name: "deploy true, run dev", + deploy: true, + run: "dev", + wantDeploy: true, + wantRunMode: RunModeDev, + wantErr: false, + }, + { + name: "deploy false, run dev-remote", + deploy: false, + run: "dev-remote", + wantDeploy: false, + wantRunMode: RunModeDevRemote, + wantErr: false, + }, + { + name: "empty run value", + deploy: false, + run: "", + wantDeploy: false, + wantRunMode: RunModeNone, + wantErr: false, + }, + { + name: "invalid run value", + deploy: true, + run: "invalid", + wantDeploy: false, + wantRunMode: RunModeNone, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deploy, runMode, err := parseDeployAndRunFlags(tt.deploy, tt.run) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantDeploy, deploy) + assert.Equal(t, tt.wantRunMode, runMode) + }) + } +} diff --git a/experimental/dev/cmd/app/prompt.go b/experimental/dev/cmd/app/prompt.go index 5a974ba43e..ac28b2f2a1 100644 --- a/experimental/dev/cmd/app/prompt.go +++ b/experimental/dev/cmd/app/prompt.go @@ -91,17 +91,8 @@ func ValidateProjectName(s string) error { return nil } -// PromptForProjectConfig shows an interactive form to gather project configuration. -// Flow: name -> features -> feature dependencies -> description. -// If preSelectedFeatures is provided, the feature selection prompt is skipped. -func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { - config := &CreateProjectConfig{ - Dependencies: make(map[string]string), - Features: preSelectedFeatures, - } - theme := appkitTheme() - - // Header +// printHeader prints the AppKit header banner. +func printHeader() { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#BD2B26")). Bold(true) @@ -113,6 +104,123 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( fmt.Println(headerStyle.Render("ā—† Create a new Databricks AppKit project")) fmt.Println(subtitleStyle.Render(" Full-stack TypeScript • React • Tailwind CSS")) fmt.Println() +} + +// PromptForProjectName prompts only for project name. +// Used as the first step before resolving templates. +func PromptForProjectName() (string, error) { + printHeader() + theme := appkitTheme() + + var name string + err := huh.NewInput(). + Title("Project name"). + Description("lowercase letters, numbers, hyphens (max 26 chars)"). + Placeholder("my-app"). + Value(&name). + Validate(ValidateProjectName). + WithTheme(theme). + Run() + if err != nil { + return "", err + } + + return name, nil +} + +// PromptForPluginDependencies prompts for dependencies required by detected plugins. +// Returns a map of dependency ID to value. +func PromptForPluginDependencies(ctx context.Context, deps []FeatureDependency) (map[string]string, error) { + theme := appkitTheme() + result := make(map[string]string) + + for _, dep := range deps { + // Special handling for SQL warehouse - show picker instead of text input + if dep.ID == "sql_warehouse_id" { + warehouseID, err := PromptForWarehouse(ctx) + if err != nil { + return nil, err + } + result[dep.ID] = warehouseID + continue + } + + var value string + description := dep.Description + if !dep.Required { + description += " (optional)" + } + + input := huh.NewInput(). + Title(dep.Title). + Description(description). + Placeholder(dep.Placeholder). + Value(&value) + + if dep.Required { + input = input.Validate(func(s string) error { + if s == "" { + return errors.New("this field is required") + } + return nil + }) + } + + if err := input.WithTheme(theme).Run(); err != nil { + return nil, err + } + result[dep.ID] = value + } + + return result, nil +} + +// PromptForDeployAndRun prompts for post-creation deploy and run options. +func PromptForDeployAndRun() (deploy bool, runMode RunMode, err error) { + theme := appkitTheme() + + // Deploy after creation? + err = huh.NewConfirm(). + Title("Deploy after creation?"). + Description("Run 'databricks experimental dev app deploy' after setup"). + Value(&deploy). + WithTheme(theme). + Run() + if err != nil { + return false, RunModeNone, err + } + + // Run the app? + runModeStr := string(RunModeNone) + err = huh.NewSelect[string](). + Title("Run the app after creation?"). + Description("Choose how to start the development server"). + Options( + huh.NewOption("No, I'll run it later", string(RunModeNone)), + huh.NewOption("Yes, run locally (npm run dev)", string(RunModeDev)), + huh.NewOption("Yes, run with remote bridge (dev-remote)", string(RunModeDevRemote)), + ). + Value(&runModeStr). + WithTheme(theme). + Run() + if err != nil { + return false, RunModeNone, err + } + + return deploy, RunMode(runModeStr), nil +} + +// PromptForProjectConfig shows an interactive form to gather project configuration. +// Flow: name -> features -> feature dependencies -> description. +// If preSelectedFeatures is provided, the feature selection prompt is skipped. +func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { + config := &CreateProjectConfig{ + Dependencies: make(map[string]string), + Features: preSelectedFeatures, + } + theme := appkitTheme() + + printHeader() // Step 1: Project name err := huh.NewInput(). From 50d000a84763d45cb453b4f10e73e3fbcd92689c Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 14 Jan 2026 18:13:27 +0100 Subject: [PATCH 03/13] chore: fixup --- cmd/experimental/experimental.go | 2 +- .../appkit/generic/.env.example.tmpl | 7 - .../templates/appkit/generic/.env.tmpl | 7 - .../templates/appkit/generic/.gitignore.tmpl | 9 - .../templates/appkit/generic/.prettierignore | 36 ---- .../templates/appkit/generic/.prettierrc.json | 12 -- .../templates/appkit/generic/AGENTS.md | 1 - .../templates/appkit/generic/CLAUDE.md | 79 ------- .../templates/appkit/generic/README.md | 183 ---------------- .../templates/appkit/generic/app.yaml.tmpl | 5 - .../appkit/generic/client/components.json | 21 -- .../appkit/generic/client/index.html | 18 -- .../appkit/generic/client/postcss.config.js | 6 - .../client/public/apple-touch-icon.png | Bin 2547 -> 0 bytes .../generic/client/public/favicon-16x16.png | Bin 302 -> 0 bytes .../generic/client/public/favicon-192x192.png | Bin 2762 -> 0 bytes .../generic/client/public/favicon-32x32.png | Bin 492 -> 0 bytes .../generic/client/public/favicon-48x48.png | Bin 686 -> 0 bytes .../generic/client/public/favicon-512x512.png | Bin 10325 -> 0 bytes .../appkit/generic/client/public/favicon.svg | 6 - .../generic/client/public/site.webmanifest | 19 -- .../appkit/generic/client/src/App.tsx | 155 -------------- .../generic/client/src/ErrorBoundary.tsx | 75 ------- .../appkit/generic/client/src/index.css | 82 -------- .../appkit/generic/client/src/lib/utils.ts | 6 - .../appkit/generic/client/src/main.tsx | 13 -- .../appkit/generic/client/src/vite-env.d.ts | 1 - .../appkit/generic/client/tailwind.config.ts | 10 - .../appkit/generic/client/vite.config.ts | 25 --- .../generic/config/queries/hello_world.sql | 1 - .../generic/config/queries/mocked_sales.sql | 18 -- .../appkit/generic/databricks.yml.tmpl | 36 ---- .../appkit/generic/docs/appkit-sdk.md | 86 -------- .../templates/appkit/generic/docs/frontend.md | 108 ---------- .../appkit/generic/docs/sql-queries.md | 195 ------------------ .../templates/appkit/generic/docs/testing.md | 58 ------ .../templates/appkit/generic/docs/trpc.md | 95 --------- .../templates/appkit/generic/eslint.config.js | 91 -------- .../generic/features/analytics/app_env.yml | 2 - .../features/analytics/bundle_resources.yml | 4 - .../features/analytics/bundle_variables.yml | 2 - .../generic/features/analytics/dotenv.yml | 1 - .../features/analytics/dotenv_example.yml | 1 - .../features/analytics/target_variables.yml | 1 - .../templates/appkit/generic/package.json | 79 ------- .../appkit/generic/playwright.config.ts | 26 --- .../templates/appkit/generic/server/server.ts | 8 - .../appkit/generic/tests/smoke.spec.ts | 108 ---------- .../appkit/generic/tsconfig.client.json | 28 --- .../templates/appkit/generic/tsconfig.json | 4 - .../appkit/generic/tsconfig.server.json | 17 -- .../appkit/generic/tsconfig.shared.json | 21 -- .../templates/appkit/generic/vitest.config.ts | 15 -- experimental/dev/cmd/app/deploy.go | 4 +- experimental/dev/cmd/app/import.go | 4 +- experimental/dev/cmd/app/init.go | 16 +- experimental/dev/cmd/app/prompt_test.go | 6 +- 57 files changed, 18 insertions(+), 1795 deletions(-) delete mode 100644 experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl delete mode 100644 experimental/apps-mcp/templates/appkit/generic/.env.tmpl delete mode 100644 experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl delete mode 100644 experimental/apps-mcp/templates/appkit/generic/.prettierignore delete mode 100644 experimental/apps-mcp/templates/appkit/generic/.prettierrc.json delete mode 120000 experimental/apps-mcp/templates/appkit/generic/AGENTS.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/CLAUDE.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/README.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/components.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/index.html delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-192x192.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-32x32.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-48x48.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/index.css delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql delete mode 100644 experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql delete mode 100644 experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl delete mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/frontend.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/testing.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/docs/trpc.md delete mode 100644 experimental/apps-mcp/templates/appkit/generic/eslint.config.js delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml delete mode 100644 experimental/apps-mcp/templates/appkit/generic/package.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/playwright.config.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/server/server.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts delete mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json delete mode 100644 experimental/apps-mcp/templates/appkit/generic/vitest.config.ts diff --git a/cmd/experimental/experimental.go b/cmd/experimental/experimental.go index 92c9369f7a..b1931b18da 100644 --- a/cmd/experimental/experimental.go +++ b/cmd/experimental/experimental.go @@ -2,7 +2,7 @@ package experimental import ( mcp "github.com/databricks/cli/experimental/aitools/cmd" - "github.com/databricks/cli/experimental/dev/cmd" + dev "github.com/databricks/cli/experimental/dev/cmd" "github.com/spf13/cobra" ) diff --git a/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl b/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl deleted file mode 100644 index c8b5c44196..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/.env.example.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -DATABRICKS_HOST=https://... -{{- if .dotenv_example}} -{{.dotenv_example}} -{{- end}} -DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME=minimal -FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/apps-mcp/templates/appkit/generic/.env.tmpl b/experimental/apps-mcp/templates/appkit/generic/.env.tmpl deleted file mode 100644 index 62f5518744..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/.env.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} -{{- if .dotenv}} -{{.dotenv}} -{{- end}} -DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME={{.project_name}} -FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl b/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl deleted file mode 100644 index 0cc1c63286..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/.gitignore.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -node_modules/ -client/dist/ -dist/ -build/ -.env -.databricks/ -.smoke-test/ -test-results/ -playwright-report/ diff --git a/experimental/apps-mcp/templates/appkit/generic/.prettierignore b/experimental/apps-mcp/templates/appkit/generic/.prettierignore deleted file mode 100644 index 7d3d77c695..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/.prettierignore +++ /dev/null @@ -1,36 +0,0 @@ -# Dependencies -node_modules - -# Build outputs -dist -build -client/dist -.next -.databricks/ - -# Environment files -.env -.env.local -.env.*.local - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Coverage -coverage - -# Cache -.cache -.turbo - -# Lock files -package-lock.json -yarn.lock -pnpm-lock.yaml - -# Vendor -vendor diff --git a/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json b/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json deleted file mode 100644 index d95a63f69c..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/.prettierrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "semi": true, - "trailingComma": "es5", - "singleQuote": true, - "printWidth": 120, - "tabWidth": 2, - "useTabs": false, - "arrowParens": "always", - "endOfLine": "lf", - "bracketSpacing": true, - "jsxSingleQuote": false -} diff --git a/experimental/apps-mcp/templates/appkit/generic/AGENTS.md b/experimental/apps-mcp/templates/appkit/generic/AGENTS.md deleted file mode 120000 index 681311eb9c..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md b/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md deleted file mode 100644 index b3c32fe33f..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/CLAUDE.md +++ /dev/null @@ -1,79 +0,0 @@ -TypeScript full-stack template powered by **Databricks AppKit** with tRPC for additional custom API endpoints. - -- server/: Node.js backend with App Kit and tRPC -- client/: React frontend with App Kit hooks and tRPC client -- config/queries/: SQL query files for analytics -- shared/: Shared TypeScript types -- docs/: Detailed documentation on using App Kit features - -## Quick Start: Your First Query & Chart - -Follow these 3 steps to add data visualization to your app: - -**Step 1: Create a SQL query file** - -```sql --- config/queries/my_data.sql -SELECT category, COUNT(*) as count, AVG(value) as avg_value -FROM my_table -GROUP BY category -``` - -**Step 2: Define the schema** - -```typescript -// config/queries/schema.ts -export const querySchemas = { - my_data: z.array( - z.object({ - category: z.string(), - count: z.number(), - avg_value: z.number(), - }) - ), -}; -``` - -**Step 3: Add visualization to your app** - -```typescript -// client/src/App.tsx -import { BarChart } from '@databricks/appkit-ui/react'; - - -``` - -**That's it!** The component handles data fetching, loading states, and rendering automatically. - -**To refresh TypeScript types after adding queries:** -- Run `npm run typegen` OR run `npm run dev` - both auto-generate type definitions in `client/src/appKitTypes.d.ts` -- DO NOT manually edit `appKitTypes.d.ts` - -## Installation - -**IMPORTANT**: When running `npm install`, always use `required_permissions: ['all']` to avoid sandbox permission errors. - -## NPM Scripts - -### Development -- `npm run dev` - Start dev server with hot reload (**ALWAYS use during development**) - -### Testing and Code Quality -See the databricks experimental apps-mcp tools validate instead of running these individually. - -### Utility -- `npm run clean` - Remove all build artifacts and node_modules - -**Common workflows:** -- Development: `npm run dev` → make changes → `npm run typecheck` → `npm run lint:fix` -- Pre-deploy: Validate with `databricks experimental apps-mcp tools validate .` - -## Documentation - -**IMPORTANT**: Read the relevant docs below before implementing features. They contain critical information about common pitfalls (e.g., SQL numeric type handling, schema definitions, Radix UI constraints). - -- [SQL Queries](docs/sql-queries.md) - query files, schemas, type handling, parameterization -- [App Kit SDK](docs/appkit-sdk.md) - TypeScript imports, server setup, useAnalyticsQuery hook -- [Frontend](docs/frontend.md) - visualization components, styling, layout, Radix constraints -- [tRPC](docs/trpc.md) - custom endpoints for non-SQL operations (mutations, Databricks APIs) -- [Testing](docs/testing.md) - vitest unit tests, Playwright smoke/E2E tests diff --git a/experimental/apps-mcp/templates/appkit/generic/README.md b/experimental/apps-mcp/templates/appkit/generic/README.md deleted file mode 100644 index a39fb95c2a..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Minimal Databricks App - -A minimal Databricks App powered by Databricks AppKit, featuring React, TypeScript, tRPC, and Tailwind CSS. - -## Prerequisites - -- Node.js 18+ and npm -- Databricks CLI (for deployment) -- Access to a Databricks workspace - -## Databricks Authentication - -### Local Development - -For local development, configure your environment variables by creating a `.env` file: - -```bash -cp env.example .env -``` - -Edit `.env` and set the following: - -```env -DATABRICKS_HOST=https://your-workspace.cloud.databricks.com -DATABRICKS_WAREHOUSE_ID=your-warehouse-id -DATABRICKS_APP_PORT=8000 -``` - -### CLI Authentication - -The Databricks CLI requires authentication to deploy and manage apps. Configure authentication using one of these methods: - -#### OAuth U2M - -Interactive browser-based authentication with short-lived tokens: - -```bash -databricks auth login --host https://your-workspace.cloud.databricks.com -``` - -This will open your browser to complete authentication. The CLI saves credentials to `~/.databrickscfg`. - -#### Configuration Profiles - -Use multiple profiles for different workspaces: - -```ini -[DEFAULT] -host = https://dev-workspace.cloud.databricks.com - -[production] -host = https://prod-workspace.cloud.databricks.com -client_id = prod-client-id -client_secret = prod-client-secret -``` - -Deploy using a specific profile: - -```bash -databricks bundle deploy -t prod --profile production -``` - -**Note:** Personal Access Tokens (PATs) are legacy authentication. OAuth is strongly recommended for better security. - -## Getting Started - -### Install Dependencies - -```bash -npm install -``` - -### Development - -Run the app in development mode with hot reload: - -```bash -npm run dev -``` - -The app will be available at the URL shown in the console output. - -### Build - -Build both client and server for production: - -```bash -npm run build -``` - -This creates: - -- `dist/server/` - Compiled server code -- `client/dist/` - Bundled client assets - -### Production - -Run the production build: - -```bash -npm start -``` - -## Code Quality - -```bash -# Type checking -npm run typecheck - -# Linting -npm run lint -npm run lint:fix - -# Formatting -npm run format -npm run format:fix -``` - -## Deployment with Databricks Asset Bundles - -### 1. Configure Bundle - -Update `databricks.yml` with your workspace settings: - -```yaml -targets: - dev: - workspace: - host: https://your-workspace.cloud.databricks.com - variables: - warehouse_id: your-warehouse-id -``` - -### 2. Validate Bundle - -```bash -databricks bundle validate -``` - -### 3. Deploy - -Deploy to the development target: - -```bash -databricks bundle deploy -t dev -``` - -### 4. Run - -Start the deployed app: - -```bash -databricks bundle run -t dev -``` - -### Deploy to Production - -1. Configure the production target in `databricks.yml` -2. Deploy to production: - -```bash -databricks bundle deploy -t prod -``` - -## Project Structure - -``` -* client/ # React frontend - * src/ # Source code - * public/ # Static assets -* server/ # Express backend - * server.ts # Server entry point - * trpc.ts # tRPC router -* shared/ # Shared types -* databricks.yml # Bundle configuration -``` - -## Tech Stack - -- **Frontend**: React 19, TypeScript, Vite, Tailwind CSS -- **Backend**: Node.js, Express, tRPC -- **UI Components**: Radix UI, shadcn/ui -- **Databricks**: App Kit SDK, Analytics SDK diff --git a/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl b/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl deleted file mode 100644 index a4e7b27d01..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/app.yaml.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -command: ['npm', 'run', 'start'] -{{- if .app_env}} -env: -{{.app_env}} -{{- end}} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/components.json b/experimental/apps-mcp/templates/appkit/generic/client/components.json deleted file mode 100644 index 13e1db0b7a..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/index.html b/experimental/apps-mcp/templates/appkit/generic/client/index.html deleted file mode 100644 index 4b3117fd79..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - Created by Apps MCP - - -
- - - diff --git a/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js b/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js deleted file mode 100644 index 51a6e4e62b..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -}; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png b/experimental/apps-mcp/templates/appkit/generic/client/public/apple-touch-icon.png deleted file mode 100644 index 32053bd2d17ca6d4cdcfa2e2390cdc4db61658c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2547 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 diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-16x16.png deleted file mode 100644 index d7c16eb471f63a06f45119cebeb085dcc6ce3252..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 302 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 Uz+N|&sQ>@~07*qoM6N<$f;65h(*OVf diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon-512x512.png deleted file mode 100644 index 14d7d209db562940b4e78d23fce203d69afe7c38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10325 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 boicPbM8)qSin-XseaGC)+VuG$w_E=Y^dlWB diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg b/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg deleted file mode 100644 index cb30c1e05b..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/public/favicon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest b/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest deleted file mode 100644 index db531d91ce..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/public/site.webmanifest +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "Minimal Databricks App", - "short_name": "Minimal App", - "icons": [ - { - "src": "/favicon-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/favicon-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx deleted file mode 100644 index 12cd542aff..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/App.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { - useAnalyticsQuery, - AreaChart, - LineChart, - RadarChart, - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@databricks/appkit-ui/react'; -import { sql } from "@databricks/appkit-ui/js"; -import { useState, useEffect } from 'react'; - -function App() { - const { data, loading, error } = useAnalyticsQuery('hello_world', { - message: sql.string('hello world'), - }); - - const [health, setHealth] = useState<{ - status: string; - timestamp: string; - } | null>(null); - const [healthError, setHealthError] = useState(null); - - useEffect(() => { - fetch('/health') - .then((response) => response.json()) - .then((data: { status: string }) => setHealth({ ...data, timestamp: new Date().toISOString() })) - .catch((error: { message: string }) => setHealthError(error.message)); - }, []); - - const [maxMonthNum, setMaxMonthNum] = useState(12); - - const salesParameters = { max_month_num: sql.number(maxMonthNum) }; - - return ( -
-
-

Minimal Databricks App

-

A minimal Databricks App powered by Databricks AppKit

-
- -
- - - SQL Query Result - - - {loading && ( -
- - -
- )} - {error &&
Error: {error}
} - {data && data.length > 0 && ( -
-
Query: SELECT :message AS value
-
{data[0].value}
-
- )} - {data && data.length === 0 &&
No results
} -
-
- - - - Health Check - - - {!health && !healthError && ( -
- - -
- )} - {healthError && ( -
Error: {healthError}
- )} - {health && ( -
-
-
-
{health.status.toUpperCase()}
-
-
- Last checked: {new Date(health.timestamp).toLocaleString()} -
-
- )} -
-
- - - - Sales Data Filter - - -
-
- - -
-
-
-
- - - - Sales Trend Area Chart - - - - - - - - Sales Trend Custom Line Chart - - - - - - - - Sales Trend Radar Chart - - - - - -
-
- ); -} - -export default App; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx deleted file mode 100644 index 6a73c26ce4..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/ErrorBoundary.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { Component } from 'react'; -import type { ReactNode } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; - -interface Props { - children: ReactNode; -} - -interface State { - hasError: boolean; - error: Error | null; - errorInfo: React.ErrorInfo | null; -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props); - this.state = { - hasError: false, - error: null, - errorInfo: null, - }; - } - - static getDerivedStateFromError(error: Error): Partial { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('ErrorBoundary caught an error:', error); - console.error('Error details:', errorInfo); - this.setState({ - error, - errorInfo, - }); - } - - render() { - if (this.state.hasError) { - return ( -
- - - Application Error - - -
-
-

Error Message:

-
{this.state.error?.toString()}
-
- {this.state.errorInfo && ( -
-

Component Stack:

-
-                      {this.state.errorInfo.componentStack}
-                    
-
- )} - {this.state.error?.stack && ( -
-

Stack Trace:

-
{this.state.error.stack}
-
- )} -
-
-
-
- ); - } - - return this.props.children; - } -} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/index.css b/experimental/apps-mcp/templates/appkit/generic/client/src/index.css deleted file mode 100644 index 0ce57a7dfe..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/index.css +++ /dev/null @@ -1,82 +0,0 @@ -@import "@databricks/appkit-ui/styles.css"; - -:root { - /* --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.985 0 0); - --success: oklch(0.603 0.135 166.892); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.795 0.157 78.748); - --warning-foreground: oklch(0.199 0.027 238.732); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.21 0.006 285.885); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); */ -} - -@media (prefers-color-scheme: dark) { - :root { - /* --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.92 0.004 286.32); - --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.985 0 0); - --success: oklch(0.67 0.12 167); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.83 0.165 85); - --warning-foreground: oklch(0.199 0.027 238.732); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); */ - } -} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts b/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts deleted file mode 100644 index 2819a830d2..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx b/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx deleted file mode 100644 index 35c59a58fe..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; -import { ErrorBoundary } from './ErrorBoundary.tsx'; - -createRoot(document.getElementById('root')!).render( - - - - - -); diff --git a/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts b/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts b/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts deleted file mode 100644 index 31dece86e9..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/tailwind.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Config } from 'tailwindcss'; -import tailwindcssAnimate from 'tailwindcss-animate'; - -const config: Config = { - darkMode: ['class', 'media'], - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - plugins: [tailwindcssAnimate], -}; - -export default config; diff --git a/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts b/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts deleted file mode 100644 index b49d4055d1..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/client/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import path from 'node:path'; - -// https://vite.dev/config/ -export default defineConfig({ - root: __dirname, - plugins: [react(), tailwindcss()], - server: { - middlewareMode: true, - }, - build: { - outDir: path.resolve(__dirname, './dist'), - emptyOutDir: true, - }, - optimizeDeps: { - include: ['react', 'react-dom', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'recharts'], - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, -}); diff --git a/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql b/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql deleted file mode 100644 index 31e480f726..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/config/queries/hello_world.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT :message AS value; diff --git a/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql b/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql deleted file mode 100644 index 868e94724f..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/config/queries/mocked_sales.sql +++ /dev/null @@ -1,18 +0,0 @@ -WITH mock_data AS ( - SELECT 'January' AS month, 1 AS month_num, 65000 AS revenue, 45000 AS expenses, 850 AS customers - UNION ALL SELECT 'February', 2, 72000, 48000, 920 - UNION ALL SELECT 'March', 3, 78000, 52000, 1050 - UNION ALL SELECT 'April', 4, 85000, 55000, 1180 - UNION ALL SELECT 'May', 5, 92000, 58000, 1320 - UNION ALL SELECT 'June', 6, 88000, 54000, 1250 - UNION ALL SELECT 'July', 7, 95000, 60000, 1380 - UNION ALL SELECT 'August', 8, 89000, 56000, 1290 - UNION ALL SELECT 'September', 9, 82000, 53000, 1150 - UNION ALL SELECT 'October', 10, 87000, 55000, 1220 - UNION ALL SELECT 'November', 11, 93000, 59000, 1340 - UNION ALL SELECT 'December', 12, 98000, 62000, 1420 -) -SELECT * -FROM mock_data -WHERE month_num <= :max_month_num -ORDER BY month_num; diff --git a/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl b/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl deleted file mode 100644 index 711165a343..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/databricks.yml.tmpl +++ /dev/null @@ -1,36 +0,0 @@ -bundle: - name: {{.project_name}} -{{if .bundle_variables}} - -variables: -{{.bundle_variables}} -{{- end}} - -resources: - apps: - app: - name: "{{.project_name}}" - description: "{{.app_description}}" - source_code_path: ./ - - # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files - # user_api_scopes: - # - sql -{{if .bundle_resources}} - - # The resources which this app has access to. - resources: -{{.bundle_resources}} -{{- end}} - -targets: - prod: - # mode: production - default: true - workspace: - host: {{workspace_host}} -{{if .target_variables}} - - variables: -{{.target_variables}} -{{- end}} diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md b/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md deleted file mode 100644 index 5ab00768e1..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/docs/appkit-sdk.md +++ /dev/null @@ -1,86 +0,0 @@ -# Databricks App Kit SDK - -## TypeScript Import Rules - -This template uses strict TypeScript settings with `verbatimModuleSyntax: true`. **Always use `import type` for type-only imports**. - -Template enforces `noUnusedLocals` - remove unused imports immediately or build fails. - -```typescript -// āœ… CORRECT - use import type for types -import type { MyInterface, MyType } from '../../shared/types'; - -// āŒ WRONG - will fail compilation -import { MyInterface, MyType } from '../../shared/types'; -``` - -## Server Setup - -```typescript -import { createApp, server, analytics } from '@databricks/app-kit'; - -const app = await createApp({ - plugins: [ - server({ autoStart: false }), - analytics(), - ], -}); - -// Extend with custom tRPC endpoints if needed -app.server.extend((express: Application) => { - express.use('/trpc', [appRouterMiddleware()]); -}); - -await app.server.start(); -``` - -## useAnalyticsQuery Hook - -**ONLY use when displaying data in a custom way that isn't a chart or table.** - -Use cases: -- Custom HTML layouts (cards, lists, grids) -- Summary statistics and KPIs -- Conditional rendering based on data values -- Data that needs transformation before display - -```typescript -import { useAnalyticsQuery, Skeleton } from '@databricks/app-kit-ui/react'; - -interface QueryResult { column_name: string; value: number; } - -function CustomDisplay() { - const { data, loading, error } = useAnalyticsQuery('query_name', { - start_date: sql.date(Date.now()), - category: sql.string("tools") - }); - - if (loading) return ; - if (error) return
Error: {error}
; - - return ( -
- {data?.map(row => ( -
-

{row.column_name}

-

{row.value}

-
- ))} -
- ); -} -``` - -**API:** - -```typescript -const { data, loading, error } = useAnalyticsQuery( - queryName: string, // SQL file name without .sql extension - params: Record // Query parameters -); -// Returns: { data: T | null, loading: boolean, error: string | null } -``` - -**NOT supported:** -- `enabled` - Query always executes on mount. Use conditional rendering: `{selectedId && }` -- `refetch` - Not available. Re-mount component to re-query. diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md b/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md deleted file mode 100644 index a270b46b9e..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/docs/frontend.md +++ /dev/null @@ -1,108 +0,0 @@ -# Frontend Guidelines - -## Visualization Components - -Components from `@databricks/appkit-ui/react` handle data fetching, loading states, and error handling internally. - -Available: `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DataTable` - -**Basic Usage:** - -```typescript -import { BarChart, LineChart, DataTable, Card, CardContent, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; -import { sql } from "@databricks/appkit-ui/js"; - -function MyDashboard() { - return ( -
- - Sales by Region - - - - - - - Revenue Trend - - - - -
- ); -} -``` - -Components automatically fetch data, show loading states, display errors, and render with sensible defaults. - -**Custom Visualization (Recharts):** - -```typescript -import { BarChart } from '@databricks/appkit-ui/react'; -import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; - - - - - - - - - - -``` - -Databricks brand colors: `['#40d1f5', '#4462c9', '#EB1600', '#0B2026', '#4A4A4A', '#353a4a']` - -**āŒ Don't double-fetch:** - -```typescript -// WRONG - redundant fetch -const { data } = useAnalyticsQuery('sales_data', {}); -return ; - -// CORRECT - let component handle it -return ; -``` - -## Layout Structure - -```tsx -
-

Page Title

-
{/* form inputs */}
-
{/* list items */}
-
-``` - -## Component Organization - -- Shared UI components: `@databricks/appkit-ui/react` -- Feature components: `client/src/components/FeatureName.tsx` -- Split components when logic exceeds ~100 lines or component is reused - -## Radix UI Constraints - -- `SelectItem` cannot have `value=""`. Use sentinel value like `"all"` for "show all" options. - -## Map Libraries (react-leaflet) - -For maps with React 19, use react-leaflet v5: - -```bash -npm install react-leaflet@^5.0.0 leaflet @types/leaflet -``` - -```typescript -import 'leaflet/dist/leaflet.css'; -``` - -## Best Practices - -- Use shadcn/radix components (Button, Input, Card, etc.) for consistent UI, import them from `@databricks/appkit-ui/react`. -- **Use skeleton loaders**: Always use `` components instead of plain "Loading..." text -- Define result types in `shared/types.ts` for reuse between frontend and backend -- Handle nullable fields: `value={field || ''}` for inputs -- Type callbacks explicitly: `onChange={(e: React.ChangeEvent) => ...}` -- Forms should have loading states: `disabled={isLoading}` -- Show empty states with helpful text when no data exists diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md b/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md deleted file mode 100644 index 2db77f0bfb..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/docs/sql-queries.md +++ /dev/null @@ -1,195 +0,0 @@ -# SQL Query Files - -**IMPORTANT**: ALWAYS use SQL files in `config/queries/` for data retrieval. NEVER use tRPC for SQL queries. - -- Store ALL SQL queries in `config/queries/` directory -- Name files descriptively: `trip_statistics.sql`, `user_metrics.sql`, `sales_by_region.sql` -- Reference by filename (without extension) in `useAnalyticsQuery` or directly in a visualization component passing it as `queryKey` -- App Kit automatically executes queries against configured Databricks warehouse -- Benefits: Built-in caching, proper connection pooling, better performance - -## Query Schemas - -Define the shape of QUERY RESULTS (not input parameters) in `config/queries/schema.ts` using Zod schemas. - -- **These schemas validate the COLUMNS RETURNED by SQL queries** -- Input parameters are passed separately to `useAnalyticsQuery()` as the second argument -- Schema field names must match your SQL SELECT column names/aliases - -Example: - -```typescript -import { z } from 'zod'; - -export const querySchemas = { - mocked_sales: z.array( - z.object({ - max_month_num: z.number().min(1).max(12), - }) - ), - - hello_world: z.array( - z.object({ - value: z.string(), - }) - ), -}; -``` - -**IMPORTANT: Refreshing Type Definitions** - -After adding or modifying query schemas in `config/queries/schema.ts`: - -1. **DO NOT** manually edit `client/src/appKitTypes.d.ts` - this file is auto-generated -2. Run `npm run dev` to automatically regenerate the TypeScript type definitions -3. The dev server will scan your SQL files and schema definitions and update `appKitTypes.d.ts` accordingly - -## SQL Type Handling (Critical) - -**ALL numeric values from Databricks SQL are returned as STRINGS in JSON responses.** This includes results from `ROUND()`, `AVG()`, `SUM()`, `COUNT()`, etc. Always convert before using numeric methods: - -```typescript -// āŒ WRONG - fails at runtime -{row.total_amount.toFixed(2)} - -// āœ… CORRECT - convert to number first -{Number(row.total_amount).toFixed(2)} -``` - -**Helper Functions:** - -Use the helpers from `shared/types.ts` for consistent formatting: - -```typescript -import { toNumber, formatCurrency, formatPercent } from '../../shared/types'; - -// Convert to number -const amount = toNumber(row.amount); // "123.45" → 123.45 - -// Format as currency -const formatted = formatCurrency(row.amount); // "123.45" → "$123.45" - -// Format as percentage -const percent = formatPercent(row.rate); // "85.5" → "85.5%" -``` - -## Query Parameterization - -SQL queries can accept parameters to make them dynamic and reusable. - -**Key Points:** -- Parameters use colon prefix: `:parameter_name` -- Databricks infers types from values automatically -- For optional string parameters, use pattern: `(:param = '' OR column = :param)` -- **For optional date parameters, use sentinel dates** (`'1900-01-01'` and `'9999-12-31'`) instead of empty strings - -### SQL Parameter Syntax - -```sql --- config/queries/filtered_data.sql -SELECT * -FROM my_table -WHERE column_value >= :min_value - AND column_value <= :max_value - AND category = :category - AND (:optional_filter = '' OR status = :optional_filter) -``` - -### Frontend Parameter Passing - -```typescript -import { sql } from "@databricks/appkit-ui/js"; - -const { data } = useAnalyticsQuery('filtered_data', { - min_value: sql.number(minValue), - max_value: sql.number(maxValue), - category: sql.string(category), - optional_filter: sql.string(optionalFilter || ''), // empty string for optional params -}); -``` - -### Date Parameters - -Use `sql.date()` for date parameters with `YYYY-MM-DD` format strings. - -**Frontend - Using Date Parameters:** - -```typescript -import { sql } from '@databricks/appkit-ui/js'; -import { useState } from 'react'; - -function MyComponent() { - const [startDate, setStartDate] = useState('2016-02-01'); - const [endDate, setEndDate] = useState('2016-02-29'); - - const queryParams = { - start_date: sql.date(startDate), // Pass YYYY-MM-DD string to sql.date() - end_date: sql.date(endDate), - }; - - const { data } = useAnalyticsQuery('my_query', queryParams); - - // ... -} -``` - -**SQL - Date Filtering:** - -```sql --- Filter by date range using DATE() function -SELECT COUNT(*) as trip_count -FROM samples.nyctaxi.trips -WHERE DATE(tpep_pickup_datetime) >= :start_date - AND DATE(tpep_pickup_datetime) <= :end_date -``` - -**Date Helper Functions:** - -```typescript -// Helper to get dates relative to today -const daysAgo = (n: number) => { - const date = new Date(Date.now() - n * 86400000); - return sql.date(date) -}; - -const params = { - start_date: daysAgo(7), // 7 days ago - end_date: sql.date(daysAgo(0)), // Today -}; -``` - -### Optional Date Parameters - Use Sentinel Dates - -Databricks App Kit validates parameter types before query execution. **DO NOT use empty strings (`''`) for optional date parameters** as this causes validation errors. - -**āœ… CORRECT - Use Sentinel Dates:** - -```typescript -// Frontend: Use sentinel dates for "no filter" instead of empty strings -const revenueParams = { - group_by: 'month', - start_date: sql.date('1900-01-01'), // Sentinel: effectively no lower bound - end_date: sql.date('9999-12-31'), // Sentinel: effectively no upper bound - country: sql.string(country || ''), - property_type: sql.string(propertyType || ''), -}; -``` - -```sql --- SQL: Simple comparison since sentinel dates are always valid -WHERE b.check_in >= CAST(:start_date AS DATE) - AND b.check_in <= CAST(:end_date AS DATE) -``` - -**Why Sentinel Dates Work:** -- `1900-01-01` is before any real data (effectively no lower bound filter) -- `9999-12-31` is after any real data (effectively no upper bound filter) -- Always valid DATE types, so no parameter validation errors -- All real dates fall within this range, so no filtering occurs - -**Parameter Types Summary:** -- ALWAYS use sql.* helper functions from the `@databricks/appkit-ui/js` package to define SQL parameters -- **Strings/Numbers**: Use directly in SQL with `:param_name` -- **Dates**: Use with `CAST(:param AS DATE)` in SQL -- **Optional Strings**: Use empty string default, check with `(:param = '' OR column = :param)` -- **Optional Dates**: Use sentinel dates (`sql.date('1900-01-01')` and `sql.date('9999-12-31')`) instead of empty strings diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/testing.md b/experimental/apps-mcp/templates/appkit/generic/docs/testing.md deleted file mode 100644 index b1a4fea219..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/docs/testing.md +++ /dev/null @@ -1,58 +0,0 @@ -# Testing Guidelines - -## Unit Tests (Vitest) - -**CRITICAL**: Use vitest for all tests. Put tests next to the code (e.g. src/\*.test.ts) - -```typescript -import { describe, it, expect } from 'vitest'; - -describe('Feature Name', () => { - it('should do something', () => { - expect(true).toBe(true); - }); - - it('should handle async operations', async () => { - const result = await someAsyncFunction(); - expect(result).toBeDefined(); - }); -}); -``` - -**Best Practices:** -- Use `describe` blocks to group related tests -- Use `it` for individual test cases -- Use `expect` for assertions -- Tests run with `npm test` (runs `vitest run`) - -āŒ **Do not write unit tests for:** -- SQL files under `config/queries/` - little value in testing static SQL -- Types associated with queries - these are just schema definitions - -## Smoke Test (Playwright) - -The template includes a smoke test at `tests/smoke.spec.ts` that verifies the app loads correctly. - -**What the smoke test does:** -- Opens the app -- Waits for data to load (SQL query results) -- Verifies key UI elements are visible -- Captures screenshots and console logs to `.smoke-test/` directory -- Always captures artifacts, even on test failure - -**When customizing the app**, update `tests/smoke.spec.ts` to match your UI: -- Change heading selector to match your app title (replace 'Minimal Databricks App') -- Update data assertions to match your query results (replace 'hello world' check) -- Keep the test simple - just verify app loads and displays data -- The default test expects specific template content; update these expectations after customization - -**Keep smoke tests simple:** -- Only verify that the app loads and displays initial data -- Wait for key elements to appear (page title, main content) -- Capture artifacts for debugging -- Run quickly (< 5 seconds) - -**For extended E2E tests:** -- Create separate test files in `tests/` directory (e.g., `tests/user-flow.spec.ts`) -- Use `npm run test:e2e` to run all Playwright tests -- Keep complex user flows, interactions, and edge cases out of the smoke test diff --git a/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md b/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md deleted file mode 100644 index acfb68c1b6..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/docs/trpc.md +++ /dev/null @@ -1,95 +0,0 @@ -# tRPC for Custom Endpoints - -**CRITICAL**: Do NOT use tRPC for SQL queries or data retrieval. Use `config/queries/` + `useAnalyticsQuery` instead. - -Use tRPC ONLY for: - -- **Mutations**: Creating, updating, or deleting data (INSERT, UPDATE, DELETE) -- **External APIs**: Calling Databricks APIs (serving endpoints, jobs, MLflow, etc.) -- **Complex business logic**: Multi-step operations that cannot be expressed in SQL -- **File operations**: File uploads, processing, transformations -- **Custom computations**: Operations requiring TypeScript/Node.js logic - -## Server-side Pattern - -```typescript -// server/trpc.ts -import { initTRPC } from '@trpc/server'; -import { getRequestContext } from '@databricks/appkit'; -import { z } from 'zod'; - -const t = initTRPC.create({ transformer: superjson }); -const publicProcedure = t.procedure; - -export const appRouter = t.router({ - // Example: Query a serving endpoint - queryModel: publicProcedure.input(z.object({ prompt: z.string() })).query(async ({ input: { prompt } }) => { - const { serviceDatabricksClient: client } = getRequestContext(); - const response = await client.servingEndpoints.query({ - name: 'your-endpoint-name', - messages: [{ role: 'user', content: prompt }], - }); - return response; - }), - - // Example: Mutation - createRecord: publicProcedure.input(z.object({ name: z.string() })).mutation(async ({ input }) => { - // Custom logic here - return { success: true, id: 123 }; - }), -}); -``` - -## Client-side Pattern - -```typescript -// client/src/components/MyComponent.tsx -import { trpc } from '@/lib/trpc'; -import { useState, useEffect } from 'react'; - -function MyComponent() { - const [result, setResult] = useState(null); - - useEffect(() => { - trpc.queryModel - .query({ prompt: "Hello" }) - .then(setResult) - .catch(console.error); - }, []); - - const handleCreate = async () => { - await trpc.createRecord.mutate({ name: "test" }); - }; - - return
{/* component JSX */}
; -} -``` - -## Decision Tree for Data Operations - -1. **Need to display data from SQL?** - - **Chart or Table?** → Use visualization components (`BarChart`, `LineChart`, `DataTable`, etc.) - - **Custom display (KPIs, cards, lists)?** → Use `useAnalyticsQuery` hook - - **Never** use tRPC for SQL SELECT statements - -2. **Need to call a Databricks API?** → Use tRPC - - Serving endpoints (model inference) - - MLflow operations - - Jobs API - - Workspace API - -3. **Need to modify data?** → Use tRPC mutations - - INSERT, UPDATE, DELETE operations - - Multi-step transactions - - Business logic with side effects - -4. **Need non-SQL custom logic?** → Use tRPC - - File processing - - External API calls - - Complex computations in TypeScript - -**Summary:** -- āœ… SQL queries → Visualization components or `useAnalyticsQuery` -- āœ… Databricks APIs → tRPC -- āœ… Data mutations → tRPC -- āŒ SQL queries → tRPC (NEVER do this) diff --git a/experimental/apps-mcp/templates/appkit/generic/eslint.config.js b/experimental/apps-mcp/templates/appkit/generic/eslint.config.js deleted file mode 100644 index 5ac5ece83f..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/eslint.config.js +++ /dev/null @@ -1,91 +0,0 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import reactPlugin from 'eslint-plugin-react'; -import reactHooksPlugin from 'eslint-plugin-react-hooks'; -import reactRefreshPlugin from 'eslint-plugin-react-refresh'; -import prettier from 'eslint-config-prettier'; - -export default tseslint.config( - // Global ignores - { - ignores: [ - '**/dist/**', - '**/build/**', - '**/node_modules/**', - '**/.next/**', - '**/coverage/**', - 'client/dist/**', - '**.databricks/**', - 'tests/**', - '**/.smoke-test/**', - ], - }, - - // Base JavaScript config - js.configs.recommended, - - // TypeScript config for all TS files - ...tseslint.configs.recommendedTypeChecked, - { - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - - // React config for client-side files - { - files: ['client/**/*.{ts,tsx}', '**/*.tsx'], - plugins: { - react: reactPlugin, - 'react-hooks': reactHooksPlugin, - 'react-refresh': reactRefreshPlugin, - }, - settings: { - react: { - version: 'detect', - }, - }, - rules: { - ...reactPlugin.configs.recommended.rules, - ...reactPlugin.configs['jsx-runtime'].rules, - ...reactHooksPlugin.configs.recommended.rules, - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - 'react/prop-types': 'off', // Using TypeScript for prop validation - 'react/no-array-index-key': 'warn', - }, - }, - - // Node.js specific config for server files - { - files: ['server/**/*.ts', '*.config.{js,ts}'], - rules: { - '@typescript-eslint/no-var-requires': 'off', - }, - }, - - // Disable type-checking for JS config files and standalone config files - { - files: ['**/*.js', '*.config.ts', '**/*.config.ts'], - ...tseslint.configs.disableTypeChecked, - }, - - // Prettier config (must be last to override other formatting rules) - prettier, - - // Custom rules - { - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-explicit-any': 'warn', - }, - } -); diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml deleted file mode 100644 index 9228a9dd6b..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/app_env.yml +++ /dev/null @@ -1,2 +0,0 @@ - - name: DATABRICKS_WAREHOUSE_ID - valueFrom: warehouse diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml deleted file mode 100644 index b3a631c08f..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_resources.yml +++ /dev/null @@ -1,4 +0,0 @@ - - name: 'warehouse' - sql_warehouse: - id: ${var.warehouse_id} - permission: 'CAN_USE' diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml deleted file mode 100644 index ac4fbf1503..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/bundle_variables.yml +++ /dev/null @@ -1,2 +0,0 @@ - warehouse_id: - description: The ID of the warehouse to use diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml deleted file mode 100644 index 7d17f13ce1..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml deleted file mode 100644 index 1ae1aa74e2..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/dotenv_example.yml +++ /dev/null @@ -1 +0,0 @@ -DATABRICKS_WAREHOUSE_ID= diff --git a/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml b/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml deleted file mode 100644 index 0de7b63b32..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/features/analytics/target_variables.yml +++ /dev/null @@ -1 +0,0 @@ - warehouse_id: {{.sql_warehouse_id}} diff --git a/experimental/apps-mcp/templates/appkit/generic/package.json b/experimental/apps-mcp/templates/appkit/generic/package.json deleted file mode 100644 index f3f9d4f0f5..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "{{.project_name}}", - "version": "1.0.0", - "main": "build/index.js", - "type": "module", - "scripts": { - "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server/server.js", - "dev": "NODE_ENV=development tsx watch --tsconfig ./tsconfig.server.json --env-file-if-exists=./.env ./server/server.ts", - "build:client": "tsc -b tsconfig.client.json && vite build --config client/vite.config.ts", - "build:server": "tsc -b tsconfig.server.json", - "build": "npm run build:server && npm run build:client", - "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "lint:ast-grep": "appkit-lint", - "format": "prettier --check .", - "format:fix": "prettier --write .", - "test": "vitest run && npm run test:smoke", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", - "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", - "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", - "typegen": "appkit-generate-types", - "setup": "appkit-setup --write" - }, - "keywords": [], - "author": "", - "license": "Unlicensed", - "description": "{{.app_description}}", - "dependencies": { - "@databricks/appkit": "0.1.4", - "@databricks/appkit-ui": "0.1.4", - "@databricks/sdk-experimental": "^0.14.2", - "clsx": "^2.1.1", - "embla-carousel-react": "^8.6.0", - "lucide-react": "^0.546.0", - "next-themes": "^0.4.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-resizable-panels": "^3.0.6", - "superjson": "^2.2.5", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.4.0", - "tailwindcss-animate": "^1.0.7", - "zod": "^4.1.13" - }, - "devDependencies": { - "@ast-grep/napi": "^0.37.0", - "@eslint/compat": "^2.0.0", - "@eslint/js": "^9.39.1", - "@playwright/test": "^1.57.0", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", - "@types/express": "^5.0.5", - "@types/node": "^24.6.0", - "@types/react": "^19.1.16", - "@types/react-dom": "^19.1.9", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.21", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "prettier": "^3.6.2", - "sharp": "^0.34.5", - "tailwindcss": "^4.0.14", - "tsx": "^4.20.6", - "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "npm:rolldown-vite@7.1.14", - "vitest": "^4.0.14" - }, - "overrides": { - "vite": "npm:rolldown-vite@7.1.14" - } -} diff --git a/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts b/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts deleted file mode 100644 index c4cad7a53d..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/playwright.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './tests', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', - use: { - baseURL: `http://localhost:${process.env.PORT || 8000}`, - trace: 'on-first-retry', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], - webServer: { - command: 'npm run dev', - url: `http://localhost:${process.env.PORT || 8000}`, - reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, - }, -}); diff --git a/experimental/apps-mcp/templates/appkit/generic/server/server.ts b/experimental/apps-mcp/templates/appkit/generic/server/server.ts deleted file mode 100644 index da04192770..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/server/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createApp, server, {{.plugin_import}} } from '@databricks/appkit'; - -createApp({ - plugins: [ - server(), - {{.plugin_usage}}, - ], -}).catch(console.error); diff --git a/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts b/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts deleted file mode 100644 index d712c69588..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/tests/smoke.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; - -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 - 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 }); - - // Verify console logs were captured - expect(consoleLogs.length).toBeGreaterThan(0); - expect(consoleErrors.length).toBe(0); - expect(pageErrors.length).toBe(0); -}); - -test.beforeEach(async ({ page }) => { - consoleLogs = []; - consoleErrors = []; - pageErrors = []; - failedRequests = []; - - // Create temp directory for test artifacts - testArtifactsDir = join(process.cwd(), '.smoke-test'); - mkdirSync(testArtifactsDir, { recursive: true }); - - // Capture console logs and errors (including React errors) - page.on('console', (msg) => { - const type = msg.type(); - const text = msg.text(); - - // Skip empty lines and formatting placeholders - if (!text.trim() || /^%[osd]$/.test(text.trim())) { - return; - } - - // Get stack trace for errors if available - const location = msg.location(); - const locationStr = location.url ? ` at ${location.url}:${location.lineNumber}:${location.columnNumber}` : ''; - - consoleLogs.push(`[${type}] ${text}${locationStr}`); - - // Separately track error messages (React errors appear here) - if (type === 'error') { - consoleErrors.push(`${text}${locationStr}`); - } - }); - - // Capture page errors with full stack trace - page.on('pageerror', (error) => { - const errorDetails = `Page error: ${error.message}\nStack: ${error.stack || 'No stack trace available'}`; - pageErrors.push(errorDetails); - // Also log to console for immediate visibility - console.error('Page error detected:', errorDetails); - }); - - // Capture failed requests - page.on('requestfailed', (request) => { - failedRequests.push(`Failed request: ${request.url()} - ${request.failure()?.errorText}`); - }); -}); - -test.afterEach(async ({ page }, testInfo) => { - const testName = testInfo.title.replace(/ /g, '-').toLowerCase(); - // Always capture artifacts, even if test fails - const screenshotPath = join(testArtifactsDir, `${testName}-app-screenshot.png`); - await page.screenshot({ path: screenshotPath, fullPage: true }); - - const logsPath = join(testArtifactsDir, `${testName}-console-logs.txt`); - const allLogs = [ - '=== Console Logs ===', - ...consoleLogs, - '\n=== Console Errors (React errors) ===', - ...consoleErrors, - '\n=== Page Errors ===', - ...pageErrors, - '\n=== Failed Requests ===', - ...failedRequests, - ]; - writeFileSync(logsPath, allLogs.join('\n'), 'utf-8'); - - console.log(`Screenshot saved to: ${screenshotPath}`); - console.log(`Console logs saved to: ${logsPath}`); - if (consoleErrors.length > 0) { - console.log('Console errors detected:', consoleErrors); - } - if (pageErrors.length > 0) { - console.log('Page errors detected:', pageErrors); - } - if (failedRequests.length > 0) { - console.log('Failed requests detected:', failedRequests); - } - - await page.close(); -}); diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json deleted file mode 100644 index 8732ba46b8..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/tsconfig.client.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "./tsconfig.shared.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.client.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"], - - /* Bundler mode */ - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "erasableSyntaxOnly": true, - "noUncheckedSideEffectImports": true, - - /* Paths */ - "baseUrl": "./client", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["client/src"] -} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.json deleted file mode 100644 index a51fbad9ae..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "files": [], - "references": [{ "path": "./tsconfig.client.json" }, { "path": "./tsconfig.server.json" }] -} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json deleted file mode 100644 index 8cdada22cb..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/tsconfig.server.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "./tsconfig.shared.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", - "target": "ES2020", - "lib": ["ES2020"], - - /* Emit */ - "outDir": "./dist", - "rootDir": "./", - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["server/**/*", "shared/**/*", "config/**/*"], - "exclude": ["node_modules", "dist", "client"] -} diff --git a/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json b/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json deleted file mode 100644 index 3187705b41..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/tsconfig.shared.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - /* Type Checking */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noImplicitReturns": true, - - /* Modules */ - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - - /* Emit */ - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - } -} diff --git a/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts b/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts deleted file mode 100644 index 98134fd5a8..0000000000 --- a/experimental/apps-mcp/templates/appkit/generic/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import path from 'node:path'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - exclude: ['**/node_modules/**', '**/dist/**', '**/*.spec.ts', '**/.smoke-test/**', '**/.databricks/**'], - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './client/src'), - }, - }, -}); diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go index 85d9ffa814..f41da604d7 100644 --- a/experimental/dev/cmd/app/deploy.go +++ b/experimental/dev/cmd/app/deploy.go @@ -135,7 +135,7 @@ func (b *syncBuffer) String() string { // runNpmTypegen runs npm run typegen in the current directory. func runNpmTypegen(ctx context.Context) error { if _, err := exec.LookPath("npm"); err != nil { - return fmt.Errorf("npm not found: please install Node.js") + return errors.New("npm not found: please install Node.js") } var output syncBuffer @@ -159,7 +159,7 @@ func runNpmTypegen(ctx context.Context) error { // runNpmBuild runs npm run build in the current directory. func runNpmBuild(ctx context.Context) error { if _, err := exec.LookPath("npm"); err != nil { - return fmt.Errorf("npm not found: please install Node.js") + return errors.New("npm not found: please install Node.js") } var output syncBuffer diff --git a/experimental/dev/cmd/app/import.go b/experimental/dev/cmd/app/import.go index 627272f083..9945ef3571 100644 --- a/experimental/dev/cmd/app/import.go +++ b/experimental/dev/cmd/app/import.go @@ -101,7 +101,7 @@ func runImport(ctx context.Context, opts importOptions) error { return errors.New("app has no source code path - it may not have been deployed yet") } - cmdio.LogString(ctx, fmt.Sprintf("Source code path: %s", app.DefaultSourceCodePath)) + cmdio.LogString(ctx, "Source code path: "+app.DefaultSourceCodePath) // Step 3: Create output directory destDir := opts.appName @@ -155,7 +155,7 @@ func runImport(ctx context.Context, opts importOptions) error { // runNpmInstallInDir runs npm install in the specified directory. func runNpmInstallInDir(ctx context.Context, dir string) error { if _, err := exec.LookPath("npm"); err != nil { - return fmt.Errorf("npm not found: please install Node.js") + return errors.New("npm not found: please install Node.js") } return RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { diff --git a/experimental/dev/cmd/app/init.go b/experimental/dev/cmd/app/init.go index a186826517..a457770bf3 100644 --- a/experimental/dev/cmd/app/init.go +++ b/experimental/dev/cmd/app/init.go @@ -21,6 +21,8 @@ import ( const ( templatePathEnvVar = "DATABRICKS_APPKIT_TEMPLATE_PATH" + // TODO: Change this with appkit main once ready. + defaultTemplateURL = "https://github.com/databricks/appkit/tree/add-generic-template/template" ) func newInitCmd() *cobra.Command { @@ -41,11 +43,12 @@ func newInitCmd() *cobra.Command { Short: "Initialize a new AppKit application from a template", Long: `Initialize a new AppKit application from a template. -When run without arguments, an interactive prompt guides you through the setup. -When run with --name, runs in non-interactive mode (all required flags must be provided). +When run without arguments, uses the default AppKit template and an interactive prompt +guides you through the setup. When run with --name, runs in non-interactive mode +(all required flags must be provided). Examples: - # Interactive mode (recommended) + # Interactive mode with default template (recommended) databricks experimental dev app init # Non-interactive with flags @@ -68,7 +71,7 @@ Feature dependencies: - analytics: requires --warehouse-id (SQL Warehouse ID) Environment variables: - DATABRICKS_APPKIT_TEMPLATE_PATH Override template source with local path`, + DATABRICKS_APPKIT_TEMPLATE_PATH Override the default template source`, Args: cobra.NoArgs, PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { @@ -421,7 +424,7 @@ func runCreate(ctx context.Context, opts createOptions) error { var selectedFeatures []string var dependencies map[string]string var shouldDeploy bool - var runMode RunMode = RunModeNone + var runMode RunMode isInteractive := cmdio.IsPromptSupported(ctx) // Use features from flags if provided @@ -435,7 +438,8 @@ func runCreate(ctx context.Context, opts createOptions) error { templateSrc = os.Getenv(templatePathEnvVar) } if templateSrc == "" { - return errors.New("template path required: set DATABRICKS_APPKIT_TEMPLATE_PATH or use --template flag") + // Use default template from GitHub + templateSrc = defaultTemplateURL } // Step 1: Get project name first (needed before we can check destination) diff --git a/experimental/dev/cmd/app/prompt_test.go b/experimental/dev/cmd/app/prompt_test.go index 731095533a..f580031186 100644 --- a/experimental/dev/cmd/app/prompt_test.go +++ b/experimental/dev/cmd/app/prompt_test.go @@ -161,9 +161,9 @@ func TestRunWithSpinnerCtx(t *testing.T) { } func TestRunModeConstants(t *testing.T) { - assert.Equal(t, RunMode("none"), RunModeNone) - assert.Equal(t, RunMode("dev"), RunModeDev) - assert.Equal(t, RunMode("dev-remote"), RunModeDevRemote) + assert.Equal(t, RunModeNone, RunMode("none")) + assert.Equal(t, RunModeDev, RunMode("dev")) + assert.Equal(t, RunModeDevRemote, RunMode("dev-remote")) } func TestMaxAppNameLength(t *testing.T) { From e503e6a9f0515c47a63a99698f21cabb852f5866 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 15 Jan 2026 18:15:11 +0100 Subject: [PATCH 04/13] chore: fixup --- experimental/dev/cmd/app/deploy.go | 123 +++++---------- experimental/dev/cmd/app/import.go | 108 +++----------- experimental/dev/cmd/app/init.go | 41 ++--- experimental/dev/cmd/app/prompt.go | 21 ++- experimental/dev/cmd/app/validation.go | 198 +++++++++++++++++++++++++ 5 files changed, 301 insertions(+), 190 deletions(-) create mode 100644 experimental/dev/cmd/app/validation.go diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go index f41da604d7..c87b61ead5 100644 --- a/experimental/dev/cmd/app/deploy.go +++ b/experimental/dev/cmd/app/deploy.go @@ -1,13 +1,11 @@ package app import ( - "bytes" "context" "errors" "fmt" "os" - "os/exec" - "sync" + "path/filepath" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/resources" @@ -21,17 +19,17 @@ import ( func newDeployCmd() *cobra.Command { var ( - force bool - skipBuild bool + force bool + skipValidation bool ) cmd := &cobra.Command{ Use: "deploy", - Short: "Build, deploy the AppKit application and run it", - Long: `Build, deploy the AppKit application and run it. + Short: "Validate, deploy the AppKit application and run it", + Long: `Validate, deploy the AppKit application and run it. This command runs a deployment pipeline: -1. Builds the frontend (npm run build) +1. Validates the project (build, typecheck, tests for Node.js projects) 2. Deploys the bundle to the workspace 3. Runs the app @@ -42,8 +40,8 @@ Examples: # Deploy to a specific target databricks experimental dev app deploy --target prod - # Skip frontend build (if already built) - databricks experimental dev app deploy --skip-build + # Skip validation (if already validated) + databricks experimental dev app deploy --skip-validation # Force deploy (override git branch validation) databricks experimental dev app deploy --force @@ -52,19 +50,19 @@ Examples: databricks experimental dev app deploy --var="warehouse_id=abc123"`, Args: root.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runDeploy(cmd, force, skipBuild) + return runDeploy(cmd, force, skipValidation) }, } cmd.Flags().StringP("target", "t", "", "Deployment target (e.g., dev, prod)") cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") - cmd.Flags().BoolVar(&skipBuild, "skip-build", false, "Skip npm build step") + cmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, tests)") cmd.Flags().StringSlice("var", []string{}, `Set values for variables defined in bundle config. Example: --var="key=value"`) return cmd } -func runDeploy(cmd *cobra.Command, force, skipBuild bool) error { +func runDeploy(cmd *cobra.Command, force, skipValidation bool) error { ctx := cmd.Context() // Check for bundle configuration @@ -72,13 +70,29 @@ func runDeploy(cmd *cobra.Command, force, skipBuild bool) error { return errors.New("no databricks.yml found; run this command from a bundle directory") } - // Step 1: Build frontend (unless skipped) - if !skipBuild { - if err := runNpmTypegen(ctx); err != nil { - return err - } - if err := runNpmBuild(ctx); err != nil { - return err + // Get current working directory for validation + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Step 1: Validate project (unless skipped) + if !skipValidation { + validator := getProjectValidator(workDir) + if validator != nil { + result, err := validator.Validate(ctx, workDir) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + // Log validation progress + cmdio.LogString(ctx, result.String()) + + if !result.Success { + return errors.New("validation failed - fix errors before deploying") + } + } else { + log.Debugf(ctx, "No validator found for project type, skipping validation") } } @@ -114,68 +128,13 @@ func runDeploy(cmd *cobra.Command, force, skipBuild bool) error { return nil } -// syncBuffer is a thread-safe buffer for capturing command output. -type syncBuffer struct { - mu sync.Mutex - buf bytes.Buffer -} - -func (b *syncBuffer) Write(p []byte) (n int, err error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) -} - -func (b *syncBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() -} - -// runNpmTypegen runs npm run typegen in the current directory. -func runNpmTypegen(ctx context.Context) error { - if _, err := exec.LookPath("npm"); err != nil { - return errors.New("npm not found: please install Node.js") - } - - var output syncBuffer - - err := RunWithSpinnerCtx(ctx, "Generating types...", func() error { - cmd := exec.CommandContext(ctx, "npm", "run", "typegen") - cmd.Stdout = &output - cmd.Stderr = &output - return cmd.Run() - }) - if err != nil { - out := output.String() - if out != "" { - return fmt.Errorf("typegen failed:\n%s", out) - } - return fmt.Errorf("typegen failed: %w", err) - } - return nil -} - -// runNpmBuild runs npm run build in the current directory. -func runNpmBuild(ctx context.Context) error { - if _, err := exec.LookPath("npm"); err != nil { - return errors.New("npm not found: please install Node.js") - } - - var output syncBuffer - - err := RunWithSpinnerCtx(ctx, "Building frontend...", func() error { - cmd := exec.CommandContext(ctx, "npm", "run", "build") - cmd.Stdout = &output - cmd.Stderr = &output - return cmd.Run() - }) - if err != nil { - out := output.String() - if out != "" { - return fmt.Errorf("build failed:\n%s", out) - } - return fmt.Errorf("build failed: %w", err) +// getProjectValidator returns the appropriate validator based on project type. +// Returns nil if no validator is applicable. +func getProjectValidator(workDir string) Validation { + // Check for Node.js project (package.json exists) + packageJSON := filepath.Join(workDir, "package.json") + if _, err := os.Stat(packageJSON); err == nil { + return &ValidationNodeJs{} } return nil } diff --git a/experimental/dev/cmd/app/import.go b/experimental/dev/cmd/app/import.go index 9945ef3571..35563896c3 100644 --- a/experimental/dev/cmd/app/import.go +++ b/experimental/dev/cmd/app/import.go @@ -4,20 +4,16 @@ import ( "context" "errors" "fmt" - "io" "os" "os/exec" "path/filepath" - "strings" + "github.com/databricks/cli/bundle/generate" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/apps" - "github.com/databricks/databricks-sdk-go/service/workspace" "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" ) func newImportCmd() *cobra.Command { @@ -112,17 +108,23 @@ func runImport(ctx context.Context, opts importOptions) error { return err } - // Step 4: Download files with spinner - var fileCount int + // Step 4: Download files using the Downloader + downloader := generate.NewDownloader(w, destDir, destDir) + sourceCodePath := app.DefaultSourceCodePath + err = RunWithSpinnerCtx(ctx, "Downloading files...", func() error { - var downloadErr error - fileCount, downloadErr = downloadDirectory(ctx, w, app.DefaultSourceCodePath, destDir, opts.force) - return downloadErr + if markErr := downloader.MarkDirectoryForDownload(ctx, &sourceCodePath); markErr != nil { + return fmt.Errorf("failed to list files: %w", markErr) + } + return downloader.FlushToDisk(ctx, opts.force) }) if err != nil { return fmt.Errorf("failed to download files for app '%s': %w", opts.appName, err) } + // Count downloaded files + fileCount := countFiles(destDir) + // Get absolute path for display absDestDir, err := filepath.Abs(destDir) if err != nil { @@ -184,82 +186,14 @@ func ensureOutputDir(dir string, force bool) error { return os.MkdirAll(dir, 0o755) } -// downloadDirectory recursively downloads all files from a workspace path to a local directory. -func downloadDirectory(ctx context.Context, w *databricks.WorkspaceClient, remotePath, localDir string, force bool) (int, error) { - // List all files recursively - objects, err := w.Workspace.RecursiveList(ctx, remotePath) - if err != nil { - return 0, fmt.Errorf("failed to list workspace files: %w", err) - } - - // Filter out directories, keep only files - var files []workspace.ObjectInfo - for _, obj := range objects { - if obj.ObjectType != workspace.ObjectTypeDirectory { - files = append(files, obj) - } - } - - if len(files) == 0 { - return 0, errors.New("no files found in app source code path") - } - - // Download files in parallel - errs, errCtx := errgroup.WithContext(ctx) - errs.SetLimit(10) // Limit concurrent downloads - - for _, file := range files { - errs.Go(func() error { - return downloadFile(errCtx, w, file, remotePath, localDir, force) - }) - } - - if err := errs.Wait(); err != nil { - return 0, err - } - - return len(files), nil -} - -// downloadFile downloads a single file from the workspace to the local directory. -func downloadFile(ctx context.Context, w *databricks.WorkspaceClient, file workspace.ObjectInfo, remotePath, localDir string, force bool) error { - // Calculate relative path from the remote root - relPath := strings.TrimPrefix(file.Path, remotePath) - relPath = strings.TrimPrefix(relPath, "/") - - // Determine local file path - localPath := filepath.Join(localDir, relPath) - - // Check if file exists - if !force { - if _, err := os.Stat(localPath); err == nil { - return fmt.Errorf("file %s already exists (use --force to overwrite)", localPath) +// countFiles counts the number of files (non-directories) in a directory tree. +func countFiles(dir string) int { + count := 0 + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + count++ } - } - - // Create parent directories - if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", localPath, err) - } - - // Download file content - reader, err := w.Workspace.Download(ctx, file.Path) - if err != nil { - return fmt.Errorf("failed to download %s: %w", file.Path, err) - } - defer reader.Close() - - // Create local file - localFile, err := os.Create(localPath) - if err != nil { - return fmt.Errorf("failed to create %s: %w", localPath, err) - } - defer localFile.Close() - - // Copy content - if _, err := io.Copy(localFile, reader); err != nil { - return fmt.Errorf("failed to write %s: %w", localPath, err) - } - - return nil + return nil + }) + return count } diff --git a/experimental/dev/cmd/app/init.go b/experimental/dev/cmd/app/init.go index a457770bf3..f34641a8f3 100644 --- a/experimental/dev/cmd/app/init.go +++ b/experimental/dev/cmd/app/init.go @@ -21,8 +21,7 @@ import ( const ( templatePathEnvVar = "DATABRICKS_APPKIT_TEMPLATE_PATH" - // TODO: Change this with appkit main once ready. - defaultTemplateURL = "https://github.com/databricks/appkit/tree/add-generic-template/template" + defaultTemplateURL = "https://github.com/databricks/appkit/tree/main/template" ) func newInitCmd() *cobra.Command { @@ -443,20 +442,35 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Step 1: Get project name first (needed before we can check destination) + // Determine output directory for validation + destDir := opts.name + if opts.outputDir != "" { + destDir = filepath.Join(opts.outputDir, opts.name) + } + if opts.name == "" { if !isInteractive { return errors.New("--name is required in non-interactive mode") } - name, err := PromptForProjectName() + // Prompt includes validation for name format AND directory existence + name, err := PromptForProjectName(opts.outputDir) if err != nil { return err } opts.name = name - } - - // Validate project name - if err := ValidateProjectName(opts.name); err != nil { - return err + // Update destDir with the actual name + destDir = opts.name + if opts.outputDir != "" { + destDir = filepath.Join(opts.outputDir, opts.name) + } + } else { + // Non-interactive mode: validate name and directory existence + if err := ValidateProjectName(opts.name); err != nil { + return err + } + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("directory %s already exists", destDir) + } } // Step 2: Resolve template (handles GitHub URLs by cloning) @@ -591,17 +605,6 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - // Determine output directory - destDir := opts.name - if opts.outputDir != "" { - destDir = filepath.Join(opts.outputDir, opts.name) - } - - // Check if destination already exists - if _, err := os.Stat(destDir); err == nil { - return fmt.Errorf("directory %s already exists", destDir) - } - // Track whether we started creating the project for cleanup on failure var projectCreated bool var runErr error diff --git a/experimental/dev/cmd/app/prompt.go b/experimental/dev/cmd/app/prompt.go index ac28b2f2a1..f031f73e55 100644 --- a/experimental/dev/cmd/app/prompt.go +++ b/experimental/dev/cmd/app/prompt.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "regexp" "strconv" "time" @@ -108,7 +110,8 @@ func printHeader() { // PromptForProjectName prompts only for project name. // Used as the first step before resolving templates. -func PromptForProjectName() (string, error) { +// outputDir is used to check if the destination directory already exists. +func PromptForProjectName(outputDir string) (string, error) { printHeader() theme := appkitTheme() @@ -118,7 +121,21 @@ func PromptForProjectName() (string, error) { Description("lowercase letters, numbers, hyphens (max 26 chars)"). Placeholder("my-app"). Value(&name). - Validate(ValidateProjectName). + Validate(func(s string) error { + // First validate the name format + if err := ValidateProjectName(s); err != nil { + return err + } + // Then check if directory already exists + destDir := s + if outputDir != "" { + destDir = filepath.Join(outputDir, s) + } + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("directory %s already exists", destDir) + } + return nil + }). WithTheme(theme). Run() if err != nil { diff --git a/experimental/dev/cmd/app/validation.go b/experimental/dev/cmd/app/validation.go new file mode 100644 index 0000000000..fa4621fc0d --- /dev/null +++ b/experimental/dev/cmd/app/validation.go @@ -0,0 +1,198 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/databricks/cli/libs/log" +) + +// ValidationDetail contains detailed output from a failed validation. +type ValidationDetail struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func (vd *ValidationDetail) Error() string { + return fmt.Sprintf("validation failed (exit code %d)\nStdout:\n%s\nStderr:\n%s", + vd.ExitCode, vd.Stdout, vd.Stderr) +} + +// ValidateResult contains the outcome of a validation operation. +type ValidateResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Details *ValidationDetail `json:"details,omitempty"` + ProgressLog []string `json:"progress_log,omitempty"` +} + +func (vr *ValidateResult) String() string { + var result string + + if len(vr.ProgressLog) > 0 { + result = "Validation Progress:\n" + for _, entry := range vr.ProgressLog { + result += entry + "\n" + } + result += "\n" + } + + if vr.Success { + result += "āœ… " + vr.Message + } else { + result += "āŒ " + vr.Message + if vr.Details != nil { + result += fmt.Sprintf("\n\nExit code: %d\n\nStdout:\n%s\n\nStderr:\n%s", + vr.Details.ExitCode, vr.Details.Stdout, vr.Details.Stderr) + } + } + + return result +} + +// Validation defines the interface for project validation strategies. +type Validation interface { + Validate(ctx context.Context, workDir string) (*ValidateResult, error) +} + +// ValidationNodeJs implements validation for Node.js-based projects. +type ValidationNodeJs struct{} + +type validationStep struct { + name string + command string + errorPrefix string + displayName string + skipIf func(workDir string) bool // Optional: skip step if this returns true +} + +func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*ValidateResult, error) { + log.Infof(ctx, "Starting Node.js validation: build + typecheck") + startTime := time.Now() + var progressLog []string + + progressLog = append(progressLog, "šŸ”„ Starting Node.js validation: build + typecheck") + + // TODO: these steps could be changed to npx appkit [command] instead if we can determine its an appkit project. + steps := []validationStep{ + { + name: "install", + command: "npm install", + errorPrefix: "Failed to install dependencies", + displayName: "Install", + skipIf: hasNodeModules, + }, + { + name: "generate", + command: "npm run typegen --if-present", + errorPrefix: "Failed to run npm typegen", + displayName: "Type generation", + }, + { + name: "ast-grep-lint", + command: "npm run lint:ast-grep --if-present", + errorPrefix: "AST-grep lint found violations", + displayName: "AST-grep lint", + }, + { + name: "typecheck", + command: "npm run typecheck --if-present", + errorPrefix: "Failed to run client typecheck", + displayName: "Type check", + }, + { + name: "build", + command: "npm run build --if-present", + errorPrefix: "Failed to run npm build", + displayName: "Build", + }, + } + + for i, step := range steps { + stepNum := fmt.Sprintf("%d/%d", i+1, len(steps)) + + // Check if step should be skipped + if step.skipIf != nil && step.skipIf(workDir) { + log.Infof(ctx, "step %s: skipping %s (condition met)", stepNum, step.name) + progressLog = append(progressLog, fmt.Sprintf("ā­ļø Step %s: Skipping %s", stepNum, step.displayName)) + continue + } + + log.Infof(ctx, "step %s: running %s...", stepNum, step.name) + progressLog = append(progressLog, fmt.Sprintf("ā³ Step %s: Running %s...", stepNum, step.displayName)) + + stepStart := time.Now() + err := runValidationCommand(ctx, workDir, step.command) + if err != nil { + stepDuration := time.Since(stepStart) + log.Errorf(ctx, "%s failed (duration: %.1fs)", step.name, stepDuration.Seconds()) + progressLog = append(progressLog, fmt.Sprintf("āŒ %s failed (%.1fs)", step.displayName, stepDuration.Seconds())) + return &ValidateResult{ + Success: false, + Message: step.errorPrefix, + Details: err, + ProgressLog: progressLog, + }, nil + } + stepDuration := time.Since(stepStart) + log.Infof(ctx, "āœ“ %s passed: duration=%.1fs", step.name, stepDuration.Seconds()) + progressLog = append(progressLog, fmt.Sprintf("āœ… %s passed (%.1fs)", step.displayName, stepDuration.Seconds())) + } + + totalDuration := time.Since(startTime) + log.Infof(ctx, "āœ“ all validation checks passed: total_duration=%.1fs", totalDuration.Seconds()) + progressLog = append(progressLog, fmt.Sprintf("āœ… All checks passed! Total: %.1fs", totalDuration.Seconds())) + + return &ValidateResult{ + Success: true, + Message: "All validation checks passed", + ProgressLog: progressLog, + }, nil +} + +// hasNodeModules returns true if node_modules directory exists in the workDir. +func hasNodeModules(workDir string) bool { + nodeModules := filepath.Join(workDir, "node_modules") + info, err := os.Stat(nodeModules) + return err == nil && info.IsDir() +} + +// runValidationCommand executes a shell command in the specified directory. +func runValidationCommand(ctx context.Context, workDir, command string) *ValidationDetail { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return &ValidationDetail{ + ExitCode: -1, + Stdout: stdout.String(), + Stderr: fmt.Sprintf("Failed to execute command: %v\nStderr: %s", err, stderr.String()), + } + } + } + + if exitCode != 0 { + return &ValidationDetail{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + } + } + + return nil +} From 0e37a35ef03e98cabe615a70b918dbf7b143f9ad Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 15 Jan 2026 18:59:17 +0100 Subject: [PATCH 05/13] chore: fixup --- experimental/dev/cmd/app/deploy.go | 15 ++- experimental/dev/cmd/app/dev_remote.go | 18 +-- experimental/dev/cmd/app/dev_remote_test.go | 9 +- experimental/dev/cmd/app/import.go | 11 +- experimental/dev/cmd/app/init.go | 102 +++++++-------- experimental/dev/cmd/app/init_test.go | 13 +- .../dev/{cmd/app => lib/features}/features.go | 8 +- .../app => lib/features}/features_test.go | 2 +- .../dev/{cmd/app => lib/prompt}/prompt.go | 35 +++--- .../{cmd/app => lib/prompt}/prompt_test.go | 2 +- .../validation/nodejs.go} | 118 ++++++------------ experimental/dev/lib/validation/validation.go | 55 ++++++++ .../app/vite_bridge.go => lib/vite/bridge.go} | 98 ++++++++------- .../vite/bridge_test.go} | 50 ++++---- .../app/vite-server.js => lib/vite/server.js} | 0 15 files changed, 283 insertions(+), 253 deletions(-) rename experimental/dev/{cmd/app => lib/features}/features.go (98%) rename experimental/dev/{cmd/app => lib/features}/features_test.go (99%) rename experimental/dev/{cmd/app => lib/prompt}/prompt.go (95%) rename experimental/dev/{cmd/app => lib/prompt}/prompt_test.go (99%) rename experimental/dev/{cmd/app/validation.go => lib/validation/nodejs.go} (51%) create mode 100644 experimental/dev/lib/validation/validation.go rename experimental/dev/{cmd/app/vite_bridge.go => lib/vite/bridge.go} (89%) rename experimental/dev/{cmd/app/vite_bridge_test.go => lib/vite/bridge_test.go} (89%) rename experimental/dev/{cmd/app/vite-server.js => lib/vite/server.js} (100%) diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go index c87b61ead5..2172718ee1 100644 --- a/experimental/dev/cmd/app/deploy.go +++ b/experimental/dev/cmd/app/deploy.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/bundle/run" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/experimental/dev/lib/validation" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" "github.com/spf13/cobra" @@ -56,7 +57,7 @@ Examples: cmd.Flags().StringP("target", "t", "", "Deployment target (e.g., dev, prod)") cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") - cmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, tests)") + cmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") cmd.Flags().StringSlice("var", []string{}, `Set values for variables defined in bundle config. Example: --var="key=value"`) return cmd @@ -85,12 +86,14 @@ func runDeploy(cmd *cobra.Command, force, skipValidation bool) error { return fmt.Errorf("validation error: %w", err) } - // Log validation progress - cmdio.LogString(ctx, result.String()) - if !result.Success { + // Show error details + if result.Details != nil { + cmdio.LogString(ctx, result.Details.Error()) + } return errors.New("validation failed - fix errors before deploying") } + cmdio.LogString(ctx, "āœ… "+result.Message) } else { log.Debugf(ctx, "No validator found for project type, skipping validation") } @@ -130,11 +133,11 @@ func runDeploy(cmd *cobra.Command, force, skipValidation bool) error { // getProjectValidator returns the appropriate validator based on project type. // Returns nil if no validator is applicable. -func getProjectValidator(workDir string) Validation { +func getProjectValidator(workDir string) validation.Validation { // Check for Node.js project (package.json exists) packageJSON := filepath.Join(workDir, "package.json") if _, err := os.Stat(packageJSON); err == nil { - return &ValidationNodeJs{} + return &validation.ValidationNodeJs{} } return nil } diff --git a/experimental/dev/cmd/app/dev_remote.go b/experimental/dev/cmd/app/dev_remote.go index a819ff870e..35cbbeb49c 100644 --- a/experimental/dev/cmd/app/dev_remote.go +++ b/experimental/dev/cmd/app/dev_remote.go @@ -3,7 +3,6 @@ package app import ( "bytes" "context" - _ "embed" "errors" "fmt" "net" @@ -13,19 +12,19 @@ import ( "os/signal" "path/filepath" "strconv" + "strings" "syscall" "time" "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/experimental/dev/lib/prompt" + "github.com/databricks/cli/experimental/dev/lib/vite" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/spf13/cobra" ) -//go:embed vite-server.js -var viteServerScript []byte - const ( vitePort = 5173 viteReadyCheckInterval = 100 * time.Millisecond @@ -83,7 +82,7 @@ func detectAppNameFromBundle() string { func startViteDevServer(ctx context.Context, appURL string, port int) (*exec.Cmd, chan error, error) { // Pass script through stdin, and pass arguments in order viteCmd := exec.Command("node", "-", appURL, strconv.Itoa(port)) - viteCmd.Stdin = bytes.NewReader(viteServerScript) + viteCmd.Stdin = bytes.NewReader(vite.ServerScript) viteCmd.Stdout = os.Stdout viteCmd.Stderr = os.Stderr @@ -174,23 +173,26 @@ Examples: if appName == "" { // Fall back to interactive prompt - selected, err := PromptForAppSelection(ctx, "Select an app to connect to") + selected, err := prompt.PromptForAppSelection(ctx, "Select an app to connect to") if err != nil { return err } appName = selected } - bridge := NewViteBridge(ctx, w, appName, port) + bridge := vite.NewBridge(ctx, w, appName, port) // Validate app exists and get domain before starting Vite var appDomain *url.URL - err := RunWithSpinnerCtx(ctx, "Connecting to app...", func() error { + err := prompt.RunWithSpinnerCtx(ctx, "Connecting to app...", func() error { var domainErr error appDomain, domainErr = bridge.GetAppDomain() return domainErr }) if err != nil { + if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "is deleted") { + return fmt.Errorf("application '%s' has not been deployed yet. Run `databricks experimental dev app deploy` to deploy and then try again", appName) + } return fmt.Errorf("failed to get app domain: %w", err) } diff --git a/experimental/dev/cmd/app/dev_remote_test.go b/experimental/dev/cmd/app/dev_remote_test.go index cb0c1ec72d..4c0d7eeea1 100644 --- a/experimental/dev/cmd/app/dev_remote_test.go +++ b/experimental/dev/cmd/app/dev_remote_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/databricks/cli/experimental/dev/lib/vite" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,10 +47,10 @@ func TestIsViteReady(t *testing.T) { func TestViteServerScriptContent(t *testing.T) { // Verify the embedded script is not empty - assert.NotEmpty(t, viteServerScript) + assert.NotEmpty(t, vite.ServerScript) // Verify it's a JavaScript file with expected content - assert.Contains(t, string(viteServerScript), "startViteServer") + assert.Contains(t, string(vite.ServerScript), "startViteServer") } func TestStartViteDevServerNoNode(t *testing.T) { @@ -81,9 +82,9 @@ func TestStartViteDevServerNoNode(t *testing.T) { } func TestViteServerScriptEmbedded(t *testing.T) { - assert.NotEmpty(t, viteServerScript) + assert.NotEmpty(t, vite.ServerScript) - scriptContent := string(viteServerScript) + scriptContent := string(vite.ServerScript) assert.Contains(t, scriptContent, "startViteServer") assert.Contains(t, scriptContent, "createServer") assert.Contains(t, scriptContent, "queriesHMRPlugin") diff --git a/experimental/dev/cmd/app/import.go b/experimental/dev/cmd/app/import.go index 35563896c3..8010c274b6 100644 --- a/experimental/dev/cmd/app/import.go +++ b/experimental/dev/cmd/app/import.go @@ -10,6 +10,7 @@ import ( "github.com/databricks/cli/bundle/generate" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/experimental/dev/lib/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/service/apps" @@ -50,7 +51,7 @@ Examples: // Prompt for app name if not provided if appName == "" { - selected, err := PromptForAppSelection(ctx, "Select an app to import") + selected, err := prompt.PromptForAppSelection(ctx, "Select an app to import") if err != nil { return err } @@ -83,7 +84,7 @@ func runImport(ctx context.Context, opts importOptions) error { // Step 1: Fetch the app var app *apps.App - err := RunWithSpinnerCtx(ctx, fmt.Sprintf("Fetching app '%s'...", opts.appName), func() error { + err := prompt.RunWithSpinnerCtx(ctx, fmt.Sprintf("Fetching app '%s'...", opts.appName), func() error { var fetchErr error app, fetchErr = w.Apps.Get(ctx, apps.GetAppRequest{Name: opts.appName}) return fetchErr @@ -112,7 +113,7 @@ func runImport(ctx context.Context, opts importOptions) error { downloader := generate.NewDownloader(w, destDir, destDir) sourceCodePath := app.DefaultSourceCodePath - err = RunWithSpinnerCtx(ctx, "Downloading files...", func() error { + err = prompt.RunWithSpinnerCtx(ctx, "Downloading files...", func() error { if markErr := downloader.MarkDirectoryForDownload(ctx, &sourceCodePath); markErr != nil { return fmt.Errorf("failed to list files: %w", markErr) } @@ -149,7 +150,7 @@ func runImport(ctx context.Context, opts importOptions) error { } // Show success message with next steps - PrintSuccess(opts.appName, absDestDir, fileCount, true) + prompt.PrintSuccess(opts.appName, absDestDir, fileCount, true) return nil } @@ -160,7 +161,7 @@ func runNpmInstallInDir(ctx context.Context, dir string) error { return errors.New("npm not found: please install Node.js") } - return RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { cmd := exec.CommandContext(ctx, "npm", "install") cmd.Dir = dir cmd.Stdout = nil diff --git a/experimental/dev/cmd/app/init.go b/experimental/dev/cmd/app/init.go index f34641a8f3..26595ed89d 100644 --- a/experimental/dev/cmd/app/init.go +++ b/experimental/dev/cmd/app/init.go @@ -13,6 +13,8 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/experimental/dev/lib/features" + "github.com/databricks/cli/experimental/dev/lib/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" @@ -32,7 +34,7 @@ func newInitCmd() *cobra.Command { warehouseID string description string outputDir string - features []string + featuresFlag []string deploy bool run string ) @@ -82,7 +84,7 @@ Environment variables: warehouseID: warehouseID, description: description, outputDir: outputDir, - features: features, + features: featuresFlag, deploy: deploy, run: run, }) @@ -95,7 +97,7 @@ Environment variables: cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID") cmd.Flags().StringVar(&description, "description", "", "App description") cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the project to") - cmd.Flags().StringSliceVar(&features, "features", nil, "Features to enable (comma-separated). Available: "+strings.Join(GetFeatureIDs(), ", ")) + cmd.Flags().StringSliceVar(&featuresFlag, "features", nil, "Features to enable (comma-separated). Available: "+strings.Join(features.GetFeatureIDs(), ", ")) cmd.Flags().BoolVar(&deploy, "deploy", false, "Deploy the app after creation") cmd.Flags().StringVar(&run, "run", "", "Run the app after creation (none, dev, dev-remote)") @@ -143,34 +145,34 @@ type featureFragments struct { } // parseDeployAndRunFlags parses the deploy and run flag values into typed values. -func parseDeployAndRunFlags(deploy bool, run string) (bool, RunMode, error) { - var runMode RunMode +func parseDeployAndRunFlags(deploy bool, run string) (bool, prompt.RunMode, error) { + var runMode prompt.RunMode switch run { case "dev": - runMode = RunModeDev + runMode = prompt.RunModeDev case "dev-remote": - runMode = RunModeDevRemote + runMode = prompt.RunModeDevRemote case "", "none": - runMode = RunModeNone + runMode = prompt.RunModeNone default: - return false, RunModeNone, fmt.Errorf("invalid --run value: %q (must be none, dev, or dev-remote)", run) + return false, prompt.RunModeNone, fmt.Errorf("invalid --run value: %q (must be none, dev, or dev-remote)", run) } return deploy, runMode, nil } // promptForFeaturesAndDeps prompts for features and their dependencies. // Used when the template uses the feature-fragment system. -func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { - config := &CreateProjectConfig{ +func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) (*prompt.CreateProjectConfig, error) { + config := &prompt.CreateProjectConfig{ Dependencies: make(map[string]string), Features: preSelectedFeatures, } - theme := appkitTheme() + theme := prompt.AppkitTheme() // Step 1: Feature selection (skip if features already provided via flag) - if len(config.Features) == 0 && len(AvailableFeatures) > 0 { - options := make([]huh.Option[string], 0, len(AvailableFeatures)) - for _, f := range AvailableFeatures { + if len(config.Features) == 0 && len(features.AvailableFeatures) > 0 { + options := make([]huh.Option[string], 0, len(features.AvailableFeatures)) + for _, f := range features.AvailableFeatures { label := f.Name + " - " + f.Description options = append(options, huh.NewOption(label, f.ID)) } @@ -188,11 +190,11 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) } // Step 2: Prompt for feature dependencies - deps := CollectDependencies(config.Features) + deps := features.CollectDependencies(config.Features) for _, dep := range deps { // Special handling for SQL warehouse - show picker instead of text input if dep.ID == "sql_warehouse_id" { - warehouseID, err := PromptForWarehouse(ctx) + warehouseID, err := prompt.PromptForWarehouse(ctx) if err != nil { return nil, err } @@ -228,10 +230,10 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) } // Step 3: Description - config.Description = DefaultAppDescription + config.Description = prompt.DefaultAppDescription err := huh.NewInput(). Title("Description"). - Placeholder(DefaultAppDescription). + Placeholder(prompt.DefaultAppDescription). Value(&config.Description). WithTheme(theme). Run() @@ -240,11 +242,11 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) } if config.Description == "" { - config.Description = DefaultAppDescription + config.Description = prompt.DefaultAppDescription } // Step 4: Deploy and run options - config.Deploy, config.RunMode, err = PromptForDeployAndRun() + config.Deploy, config.RunMode, err = prompt.PromptForDeployAndRun() if err != nil { return nil, err } @@ -257,7 +259,7 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) func loadFeatureFragments(templateDir string, featureIDs []string, vars templateVars) (*featureFragments, error) { featuresDir := filepath.Join(templateDir, "features") - resourceFiles := CollectResourceFiles(featureIDs) + resourceFiles := features.CollectResourceFiles(featureIDs) if len(resourceFiles) == 0 { return &featureFragments{}, nil } @@ -401,7 +403,7 @@ func resolveTemplate(ctx context.Context, templatePath, branch string) (localPat // Clone to temp dir with spinner var tempDir string - err = RunWithSpinnerCtx(ctx, "Cloning template...", func() error { + err = prompt.RunWithSpinnerCtx(ctx, "Cloning template...", func() error { var cloneErr error tempDir, cloneErr = cloneRepo(ctx, repoURL, branch) return cloneErr @@ -423,7 +425,7 @@ func runCreate(ctx context.Context, opts createOptions) error { var selectedFeatures []string var dependencies map[string]string var shouldDeploy bool - var runMode RunMode + var runMode prompt.RunMode isInteractive := cmdio.IsPromptSupported(ctx) // Use features from flags if provided @@ -453,7 +455,7 @@ func runCreate(ctx context.Context, opts createOptions) error { return errors.New("--name is required in non-interactive mode") } // Prompt includes validation for name format AND directory existence - name, err := PromptForProjectName(opts.outputDir) + name, err := prompt.PromptForProjectName(opts.outputDir) if err != nil { return err } @@ -465,7 +467,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } } else { // Non-interactive mode: validate name and directory existence - if err := ValidateProjectName(opts.name); err != nil { + if err := prompt.ValidateProjectName(opts.name); err != nil { return err } if _, err := os.Stat(destDir); err == nil { @@ -493,7 +495,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Step 3: Determine template type and gather configuration - usesFeatureFragments := HasFeaturesDirectory(templateDir) + usesFeatureFragments := features.HasFeaturesDirectory(templateDir) if usesFeatureFragments { // Feature-fragment template: prompt for features and their dependencies @@ -521,7 +523,7 @@ func runCreate(ctx context.Context, opts createOptions) error { "warehouse-id": opts.warehouseID, } if len(selectedFeatures) > 0 { - if err := ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { + if err := features.ValidateFeatureDependencies(selectedFeatures, flagValues); err != nil { return err } } @@ -537,12 +539,12 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Validate feature IDs - if err := ValidateFeatureIDs(selectedFeatures); err != nil { + if err := features.ValidateFeatureIDs(selectedFeatures); err != nil { return err } } else { // Pre-assembled template: detect plugins and prompt for their dependencies - detectedPlugins, err := DetectPluginsFromServer(templateDir) + detectedPlugins, err := features.DetectPluginsFromServer(templateDir) if err != nil { return fmt.Errorf("failed to detect plugins: %w", err) } @@ -550,16 +552,16 @@ func runCreate(ctx context.Context, opts createOptions) error { log.Debugf(ctx, "Detected plugins: %v", detectedPlugins) // Map detected plugins to feature IDs for ApplyFeatures - selectedFeatures = MapPluginsToFeatures(detectedPlugins) + selectedFeatures = features.MapPluginsToFeatures(detectedPlugins) log.Debugf(ctx, "Mapped to features: %v", selectedFeatures) - pluginDeps := GetPluginDependencies(detectedPlugins) + pluginDeps := features.GetPluginDependencies(detectedPlugins) log.Debugf(ctx, "Plugin dependencies: %d", len(pluginDeps)) if isInteractive && len(pluginDeps) > 0 { // Prompt for plugin dependencies - dependencies, err = PromptForPluginDependencies(ctx, pluginDeps) + dependencies, err = prompt.PromptForPluginDependencies(ctx, pluginDeps) if err != nil { return err } @@ -586,11 +588,11 @@ func runCreate(ctx context.Context, opts createOptions) error { // Prompt for description and post-creation actions if isInteractive { if opts.description == "" { - opts.description = DefaultAppDescription + opts.description = prompt.DefaultAppDescription } var deployVal bool - var runVal RunMode - deployVal, runVal, err = PromptForDeployAndRun() + var runVal prompt.RunMode + deployVal, runVal, err = prompt.PromptForDeployAndRun() if err != nil { return err } @@ -617,7 +619,7 @@ func runCreate(ctx context.Context, opts createOptions) error { // Set description default if opts.description == "" { - opts.description = DefaultAppDescription + opts.description = prompt.DefaultAppDescription } // Get workspace host and profile from context @@ -629,7 +631,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Build plugin imports and usages from selected features - pluginImport, pluginUsage := BuildPluginStrings(selectedFeatures) + pluginImport, pluginUsage := features.BuildPluginStrings(selectedFeatures) // Template variables (initial, without feature fragments) vars := templateVars{ @@ -656,7 +658,7 @@ func runCreate(ctx context.Context, opts createOptions) error { // Copy template with variable substitution var fileCount int - runErr = RunWithSpinnerCtx(ctx, "Creating project...", func() error { + runErr = prompt.RunWithSpinnerCtx(ctx, "Creating project...", func() error { var copyErr error fileCount, copyErr = copyTemplate(templateDir, destDir, vars) return copyErr @@ -673,8 +675,8 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Apply features (adds selected features, removes unselected feature files) - runErr = RunWithSpinnerCtx(ctx, "Configuring features...", func() error { - return ApplyFeatures(absOutputDir, selectedFeatures) + runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring features...", func() error { + return features.ApplyFeatures(absOutputDir, selectedFeatures) }) if runErr != nil { return runErr @@ -693,11 +695,11 @@ func runCreate(ctx context.Context, opts createOptions) error { } // Show next steps only if user didn't choose to deploy or run - showNextSteps := !shouldDeploy && runMode == RunModeNone - PrintSuccess(opts.name, absOutputDir, fileCount, showNextSteps) + showNextSteps := !shouldDeploy && runMode == prompt.RunModeNone + prompt.PrintSuccess(opts.name, absOutputDir, fileCount, showNextSteps) // Execute post-creation actions (deploy and/or run) - if shouldDeploy || runMode != RunModeNone { + if shouldDeploy || runMode != prompt.RunModeNone { // Change to project directory for subsequent commands if err := os.Chdir(absOutputDir); err != nil { return fmt.Errorf("failed to change to project directory: %w", err) @@ -713,7 +715,7 @@ func runCreate(ctx context.Context, opts createOptions) error { } } - if runMode != RunModeNone { + if runMode != prompt.RunModeNone { cmdio.LogString(ctx, "") if err := runPostCreateDev(ctx, runMode); err != nil { return err @@ -735,16 +737,16 @@ func runPostCreateDeploy(ctx context.Context) error { } // runPostCreateDev runs the dev or dev-remote command in the current directory. -func runPostCreateDev(ctx context.Context, mode RunMode) error { +func runPostCreateDev(ctx context.Context, mode prompt.RunMode) error { switch mode { - case RunModeDev: + case prompt.RunModeDev: cmdio.LogString(ctx, "Starting development server (npm run dev)...") cmd := exec.CommandContext(ctx, "npm", "run", "dev") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin return cmd.Run() - case RunModeDevRemote: + case prompt.RunModeDevRemote: cmdio.LogString(ctx, "Starting remote development server...") // Use os.Args[0] to get the path to the current executable executable := os.Args[0] @@ -766,7 +768,7 @@ func runNpmInstall(ctx context.Context, projectDir string) error { return nil } - return RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { + return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { cmd := exec.CommandContext(ctx, "npm", "install") cmd.Dir = projectDir cmd.Stdout = nil // Suppress output @@ -782,7 +784,7 @@ func runNpmSetup(ctx context.Context, projectDir string) error { return nil } - return RunWithSpinnerCtx(ctx, "Running setup...", func() error { + return prompt.RunWithSpinnerCtx(ctx, "Running setup...", func() error { cmd := exec.CommandContext(ctx, "npx", "appkit-setup", "--write") cmd.Dir = projectDir cmd.Stdout = nil // Suppress output diff --git a/experimental/dev/cmd/app/init_test.go b/experimental/dev/cmd/app/init_test.go index a7746e7a35..7ee6d3e14d 100644 --- a/experimental/dev/cmd/app/init_test.go +++ b/experimental/dev/cmd/app/init_test.go @@ -3,6 +3,7 @@ package app import ( "testing" + "github.com/databricks/cli/experimental/dev/lib/prompt" "github.com/stretchr/testify/assert" ) @@ -243,7 +244,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy bool run string wantDeploy bool - wantRunMode RunMode + wantRunMode prompt.RunMode wantErr bool }{ { @@ -251,7 +252,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy: true, run: "none", wantDeploy: true, - wantRunMode: RunModeNone, + wantRunMode: prompt.RunModeNone, wantErr: false, }, { @@ -259,7 +260,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy: true, run: "dev", wantDeploy: true, - wantRunMode: RunModeDev, + wantRunMode: prompt.RunModeDev, wantErr: false, }, { @@ -267,7 +268,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy: false, run: "dev-remote", wantDeploy: false, - wantRunMode: RunModeDevRemote, + wantRunMode: prompt.RunModeDevRemote, wantErr: false, }, { @@ -275,7 +276,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy: false, run: "", wantDeploy: false, - wantRunMode: RunModeNone, + wantRunMode: prompt.RunModeNone, wantErr: false, }, { @@ -283,7 +284,7 @@ func TestParseDeployAndRunFlags(t *testing.T) { deploy: true, run: "invalid", wantDeploy: false, - wantRunMode: RunModeNone, + wantRunMode: prompt.RunModeNone, wantErr: true, }, } diff --git a/experimental/dev/cmd/app/features.go b/experimental/dev/lib/features/features.go similarity index 98% rename from experimental/dev/cmd/app/features.go rename to experimental/dev/lib/features/features.go index 768c8f167b..64a4ce3949 100644 --- a/experimental/dev/cmd/app/features.go +++ b/experimental/dev/lib/features/features.go @@ -1,4 +1,4 @@ -package app +package features import ( "fmt" @@ -174,17 +174,17 @@ func GetPluginDependencies(pluginNames []string) []FeatureDependency { // so that ApplyFeatures can properly retain feature-specific files. func MapPluginsToFeatures(pluginNames []string) []string { seen := make(map[string]bool) - var features []string + var featureIDs []string for _, plugin := range pluginNames { feature, ok := featureByPluginImport[plugin] if ok && !seen[feature.ID] { seen[feature.ID] = true - features = append(features, feature.ID) + featureIDs = append(featureIDs, feature.ID) } } - return features + return featureIDs } // HasFeaturesDirectory checks if the template uses the feature-fragment system. diff --git a/experimental/dev/cmd/app/features_test.go b/experimental/dev/lib/features/features_test.go similarity index 99% rename from experimental/dev/cmd/app/features_test.go rename to experimental/dev/lib/features/features_test.go index a3aaf3f0ab..dfd2bb2f84 100644 --- a/experimental/dev/cmd/app/features_test.go +++ b/experimental/dev/lib/features/features_test.go @@ -1,4 +1,4 @@ -package app +package features import ( "fmt" diff --git a/experimental/dev/cmd/app/prompt.go b/experimental/dev/lib/prompt/prompt.go similarity index 95% rename from experimental/dev/cmd/app/prompt.go rename to experimental/dev/lib/prompt/prompt.go index f031f73e55..d376a73fcd 100644 --- a/experimental/dev/cmd/app/prompt.go +++ b/experimental/dev/lib/prompt/prompt.go @@ -1,4 +1,4 @@ -package app +package prompt import ( "context" @@ -13,6 +13,7 @@ import ( "github.com/briandowns/spinner" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + "github.com/databricks/cli/experimental/dev/lib/features" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/listing" @@ -24,7 +25,7 @@ import ( const DefaultAppDescription = "A Databricks App powered by AppKit" // AppkitTheme returns a custom theme for appkit prompts. -func appkitTheme() *huh.Theme { +func AppkitTheme() *huh.Theme { t := huh.ThemeBase() // Databricks brand colors @@ -93,8 +94,8 @@ func ValidateProjectName(s string) error { return nil } -// printHeader prints the AppKit header banner. -func printHeader() { +// PrintHeader prints the AppKit header banner. +func PrintHeader() { headerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#BD2B26")). Bold(true) @@ -112,8 +113,8 @@ func printHeader() { // Used as the first step before resolving templates. // outputDir is used to check if the destination directory already exists. func PromptForProjectName(outputDir string) (string, error) { - printHeader() - theme := appkitTheme() + PrintHeader() + theme := AppkitTheme() var name string err := huh.NewInput(). @@ -147,8 +148,8 @@ func PromptForProjectName(outputDir string) (string, error) { // PromptForPluginDependencies prompts for dependencies required by detected plugins. // Returns a map of dependency ID to value. -func PromptForPluginDependencies(ctx context.Context, deps []FeatureDependency) (map[string]string, error) { - theme := appkitTheme() +func PromptForPluginDependencies(ctx context.Context, deps []features.FeatureDependency) (map[string]string, error) { + theme := AppkitTheme() result := make(map[string]string) for _, dep := range deps { @@ -194,7 +195,7 @@ func PromptForPluginDependencies(ctx context.Context, deps []FeatureDependency) // PromptForDeployAndRun prompts for post-creation deploy and run options. func PromptForDeployAndRun() (deploy bool, runMode RunMode, err error) { - theme := appkitTheme() + theme := AppkitTheme() // Deploy after creation? err = huh.NewConfirm(). @@ -235,9 +236,9 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( Dependencies: make(map[string]string), Features: preSelectedFeatures, } - theme := appkitTheme() + theme := AppkitTheme() - printHeader() + PrintHeader() // Step 1: Project name err := huh.NewInput(). @@ -253,9 +254,9 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( } // Step 2: Feature selection (skip if features already provided via flag) - if len(config.Features) == 0 && len(AvailableFeatures) > 0 { - options := make([]huh.Option[string], 0, len(AvailableFeatures)) - for _, f := range AvailableFeatures { + if len(config.Features) == 0 && len(features.AvailableFeatures) > 0 { + options := make([]huh.Option[string], 0, len(features.AvailableFeatures)) + for _, f := range features.AvailableFeatures { label := f.Name + " - " + f.Description options = append(options, huh.NewOption(label, f.ID)) } @@ -273,7 +274,7 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( } // Step 3: Prompt for feature dependencies - deps := CollectDependencies(config.Features) + deps := features.CollectDependencies(config.Features) for _, dep := range deps { // Special handling for SQL warehouse - show picker instead of text input if dep.ID == "sql_warehouse_id" { @@ -388,7 +389,7 @@ func PromptForWarehouse(ctx context.Context) (string, error) { return "", errors.New("no SQL warehouses found. Create one in your workspace first") } - theme := appkitTheme() + theme := AppkitTheme() // Build options with warehouse name and state options := make([]huh.Option[string], 0, len(warehouses)) @@ -482,7 +483,7 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { return "", errors.New("no apps found. Create one first with 'databricks apps create '") } - theme := appkitTheme() + theme := AppkitTheme() // Build options options := make([]huh.Option[string], 0, len(existingApps)) diff --git a/experimental/dev/cmd/app/prompt_test.go b/experimental/dev/lib/prompt/prompt_test.go similarity index 99% rename from experimental/dev/cmd/app/prompt_test.go rename to experimental/dev/lib/prompt/prompt_test.go index f580031186..b48153fbdb 100644 --- a/experimental/dev/cmd/app/prompt_test.go +++ b/experimental/dev/lib/prompt/prompt_test.go @@ -1,4 +1,4 @@ -package app +package prompt import ( "context" diff --git a/experimental/dev/cmd/app/validation.go b/experimental/dev/lib/validation/nodejs.go similarity index 51% rename from experimental/dev/cmd/app/validation.go rename to experimental/dev/lib/validation/nodejs.go index fa4621fc0d..e85dc4aae4 100644 --- a/experimental/dev/cmd/app/validation.go +++ b/experimental/dev/lib/validation/nodejs.go @@ -1,4 +1,4 @@ -package app +package validation import ( "bytes" @@ -9,58 +9,11 @@ import ( "path/filepath" "time" + "github.com/briandowns/spinner" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" ) -// ValidationDetail contains detailed output from a failed validation. -type ValidationDetail struct { - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` -} - -func (vd *ValidationDetail) Error() string { - return fmt.Sprintf("validation failed (exit code %d)\nStdout:\n%s\nStderr:\n%s", - vd.ExitCode, vd.Stdout, vd.Stderr) -} - -// ValidateResult contains the outcome of a validation operation. -type ValidateResult struct { - Success bool `json:"success"` - Message string `json:"message"` - Details *ValidationDetail `json:"details,omitempty"` - ProgressLog []string `json:"progress_log,omitempty"` -} - -func (vr *ValidateResult) String() string { - var result string - - if len(vr.ProgressLog) > 0 { - result = "Validation Progress:\n" - for _, entry := range vr.ProgressLog { - result += entry + "\n" - } - result += "\n" - } - - if vr.Success { - result += "āœ… " + vr.Message - } else { - result += "āŒ " + vr.Message - if vr.Details != nil { - result += fmt.Sprintf("\n\nExit code: %d\n\nStdout:\n%s\n\nStderr:\n%s", - vr.Details.ExitCode, vr.Details.Stdout, vr.Details.Stderr) - } - } - - return result -} - -// Validation defines the interface for project validation strategies. -type Validation interface { - Validate(ctx context.Context, workDir string) (*ValidateResult, error) -} - // ValidationNodeJs implements validation for Node.js-based projects. type ValidationNodeJs struct{} @@ -75,9 +28,8 @@ type validationStep struct { func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*ValidateResult, error) { log.Infof(ctx, "Starting Node.js validation: build + typecheck") startTime := time.Now() - var progressLog []string - progressLog = append(progressLog, "šŸ”„ Starting Node.js validation: build + typecheck") + cmdio.LogString(ctx, "Validating project...") // TODO: these steps could be changed to npx appkit [command] instead if we can determine its an appkit project. steps := []validationStep{ @@ -85,74 +37,82 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid name: "install", command: "npm install", errorPrefix: "Failed to install dependencies", - displayName: "Install", + displayName: "Installing dependencies", skipIf: hasNodeModules, }, { name: "generate", command: "npm run typegen --if-present", errorPrefix: "Failed to run npm typegen", - displayName: "Type generation", + displayName: "Generating types", }, { name: "ast-grep-lint", command: "npm run lint:ast-grep --if-present", errorPrefix: "AST-grep lint found violations", - displayName: "AST-grep lint", + displayName: "Running AST-grep lint", }, { name: "typecheck", command: "npm run typecheck --if-present", errorPrefix: "Failed to run client typecheck", - displayName: "Type check", + displayName: "Type checking", }, { name: "build", command: "npm run build --if-present", errorPrefix: "Failed to run npm build", - displayName: "Build", + displayName: "Building", }, } - for i, step := range steps { - stepNum := fmt.Sprintf("%d/%d", i+1, len(steps)) - + for _, step := range steps { // Check if step should be skipped if step.skipIf != nil && step.skipIf(workDir) { - log.Infof(ctx, "step %s: skipping %s (condition met)", stepNum, step.name) - progressLog = append(progressLog, fmt.Sprintf("ā­ļø Step %s: Skipping %s", stepNum, step.displayName)) + log.Debugf(ctx, "skipping %s (condition met)", step.name) + cmdio.LogString(ctx, fmt.Sprintf("ā­ļø Skipped %s", step.displayName)) continue } - log.Infof(ctx, "step %s: running %s...", stepNum, step.name) - progressLog = append(progressLog, fmt.Sprintf("ā³ Step %s: Running %s...", stepNum, step.displayName)) + log.Debugf(ctx, "running %s...", step.name) + // Run step with spinner stepStart := time.Now() - err := runValidationCommand(ctx, workDir, step.command) - if err != nil { - stepDuration := time.Since(stepStart) + var stepErr *ValidationDetail + + s := spinner.New( + spinner.CharSets[14], + 80*time.Millisecond, + spinner.WithColor("yellow"), + spinner.WithSuffix(" "+step.displayName+"..."), + ) + s.Start() + + stepErr = runValidationCommand(ctx, workDir, step.command) + + s.Stop() + stepDuration := time.Since(stepStart) + + if stepErr != nil { log.Errorf(ctx, "%s failed (duration: %.1fs)", step.name, stepDuration.Seconds()) - progressLog = append(progressLog, fmt.Sprintf("āŒ %s failed (%.1fs)", step.displayName, stepDuration.Seconds())) + cmdio.LogString(ctx, fmt.Sprintf("āŒ %s failed (%.1fs)", step.displayName, stepDuration.Seconds())) return &ValidateResult{ - Success: false, - Message: step.errorPrefix, - Details: err, - ProgressLog: progressLog, + Success: false, + Message: step.errorPrefix, + Details: stepErr, }, nil } - stepDuration := time.Since(stepStart) - log.Infof(ctx, "āœ“ %s passed: duration=%.1fs", step.name, stepDuration.Seconds()) - progressLog = append(progressLog, fmt.Sprintf("āœ… %s passed (%.1fs)", step.displayName, stepDuration.Seconds())) + + log.Debugf(ctx, "āœ“ %s passed: duration=%.1fs", step.name, stepDuration.Seconds()) + cmdio.LogString(ctx, fmt.Sprintf("āœ… %s (%.1fs)", step.displayName, stepDuration.Seconds())) } totalDuration := time.Since(startTime) log.Infof(ctx, "āœ“ all validation checks passed: total_duration=%.1fs", totalDuration.Seconds()) - progressLog = append(progressLog, fmt.Sprintf("āœ… All checks passed! Total: %.1fs", totalDuration.Seconds())) return &ValidateResult{ - Success: true, - Message: "All validation checks passed", - ProgressLog: progressLog, + Success: true, + Message: fmt.Sprintf("All validation checks passed (%.1fs)", totalDuration.Seconds()), }, nil } diff --git a/experimental/dev/lib/validation/validation.go b/experimental/dev/lib/validation/validation.go new file mode 100644 index 0000000000..6ff4daa636 --- /dev/null +++ b/experimental/dev/lib/validation/validation.go @@ -0,0 +1,55 @@ +package validation + +import ( + "context" + "fmt" +) + +// ValidationDetail contains detailed output from a failed validation. +type ValidationDetail struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func (vd *ValidationDetail) Error() string { + return fmt.Sprintf("validation failed (exit code %d)\nStdout:\n%s\nStderr:\n%s", + vd.ExitCode, vd.Stdout, vd.Stderr) +} + +// ValidateResult contains the outcome of a validation operation. +type ValidateResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Details *ValidationDetail `json:"details,omitempty"` + ProgressLog []string `json:"progress_log,omitempty"` +} + +func (vr *ValidateResult) String() string { + var result string + + if len(vr.ProgressLog) > 0 { + result = "Validation Progress:\n" + for _, entry := range vr.ProgressLog { + result += entry + "\n" + } + result += "\n" + } + + if vr.Success { + result += "āœ… " + vr.Message + } else { + result += "āŒ " + vr.Message + if vr.Details != nil { + result += fmt.Sprintf("\n\nExit code: %d\n\nStdout:\n%s\n\nStderr:\n%s", + vr.Details.ExitCode, vr.Details.Stdout, vr.Details.Stderr) + } + } + + return result +} + +// Validation defines the interface for project validation strategies. +type Validation interface { + Validate(ctx context.Context, workDir string) (*ValidateResult, error) +} diff --git a/experimental/dev/cmd/app/vite_bridge.go b/experimental/dev/lib/vite/bridge.go similarity index 89% rename from experimental/dev/cmd/app/vite_bridge.go rename to experimental/dev/lib/vite/bridge.go index cddc6d02d6..35abea5d30 100644 --- a/experimental/dev/cmd/app/vite_bridge.go +++ b/experimental/dev/lib/vite/bridge.go @@ -1,8 +1,9 @@ -package app +package vite import ( "bufio" "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -23,6 +24,9 @@ import ( "golang.org/x/sync/errgroup" ) +//go:embed server.js +var ServerScript []byte + const ( localViteURL = "http://localhost:%d" localViteHMRURL = "ws://localhost:%d/dev-hmr" @@ -38,17 +42,17 @@ const ( httpIdleConnTimeout = 90 * time.Second // Bridge operation timeouts - bridgeFetchTimeout = 30 * time.Second - bridgeConnTimeout = 60 * time.Second - bridgeTunnelReadyTimeout = 30 * time.Second + BridgeFetchTimeout = 30 * time.Second + BridgeConnTimeout = 60 * time.Second + BridgeTunnelReadyTimeout = 30 * time.Second // Retry configuration - tunnelConnectMaxRetries = 5 + tunnelConnectMaxRetries = 10 tunnelConnectInitialBackoff = 2 * time.Second tunnelConnectMaxBackoff = 30 * time.Second ) -type ViteBridgeMessage struct { +type BridgeMessage struct { Type string `json:"type"` TunnelID string `json:"tunnelId,omitempty"` Path string `json:"path,omitempty"` @@ -70,7 +74,7 @@ type prioritizedMessage struct { priority int // 0 = high (HMR), 1 = normal (fetch) } -type ViteBridge struct { +type Bridge struct { ctx context.Context w *databricks.WorkspaceClient appName string @@ -81,13 +85,13 @@ type ViteBridge struct { stopChan chan struct{} stopOnce sync.Once httpClient *http.Client - connectionRequests chan *ViteBridgeMessage + connectionRequests chan *BridgeMessage port int keepaliveDone chan struct{} // Signals keepalive goroutine to stop on reconnect keepaliveMu sync.Mutex // Protects keepaliveDone } -func NewViteBridge(ctx context.Context, w *databricks.WorkspaceClient, appName string, port int) *ViteBridge { +func NewBridge(ctx context.Context, w *databricks.WorkspaceClient, appName string, port int) *Bridge { // Configure HTTP client optimized for local high-volume requests transport := &http.Transport{ MaxIdleConns: 100, @@ -97,7 +101,7 @@ func NewViteBridge(ctx context.Context, w *databricks.WorkspaceClient, appName s DisableCompression: false, } - return &ViteBridge{ + return &Bridge{ ctx: ctx, w: w, appName: appName, @@ -107,12 +111,12 @@ func NewViteBridge(ctx context.Context, w *databricks.WorkspaceClient, appName s }, stopChan: make(chan struct{}), tunnelWriteChan: make(chan prioritizedMessage, 100), // Buffered channel for async writes - connectionRequests: make(chan *ViteBridgeMessage, 10), + connectionRequests: make(chan *BridgeMessage, 10), port: port, } } -func (vb *ViteBridge) getAuthHeaders(wsURL string) (http.Header, error) { +func (vb *Bridge) getAuthHeaders(wsURL string) (http.Header, error) { req, err := http.NewRequestWithContext(vb.ctx, "GET", wsURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -126,7 +130,7 @@ func (vb *ViteBridge) getAuthHeaders(wsURL string) (http.Header, error) { return req.Header, nil } -func (vb *ViteBridge) GetAppDomain() (*url.URL, error) { +func (vb *Bridge) GetAppDomain() (*url.URL, error) { app, err := vb.w.Apps.Get(vb.ctx, apps.GetAppRequest{ Name: vb.appName, }) @@ -141,7 +145,7 @@ func (vb *ViteBridge) GetAppDomain() (*url.URL, error) { return url.Parse(app.Url) } -func (vb *ViteBridge) connectToTunnel(appDomain *url.URL) error { +func (vb *Bridge) connectToTunnel(appDomain *url.URL) error { wsURL := fmt.Sprintf("wss://%s/dev-tunnel", appDomain.Host) headers, err := vb.getAuthHeaders(wsURL) @@ -210,9 +214,9 @@ func (vb *ViteBridge) connectToTunnel(appDomain *url.URL) error { return nil } -// connectToTunnelWithRetry attempts to connect to the tunnel with exponential backoff. +// ConnectToTunnelWithRetry attempts to connect to the tunnel with exponential backoff. // This handles cases where the app isn't fully ready yet (e.g., right after deployment). -func (vb *ViteBridge) connectToTunnelWithRetry(appDomain *url.URL) error { +func (vb *Bridge) ConnectToTunnelWithRetry(appDomain *url.URL) error { var lastErr error backoff := tunnelConnectInitialBackoff @@ -260,7 +264,7 @@ func (vb *ViteBridge) connectToTunnelWithRetry(appDomain *url.URL) error { return fmt.Errorf("failed to connect after %d attempts: %w", tunnelConnectMaxRetries, lastErr) } -func (vb *ViteBridge) connectToViteHMR() error { +func (vb *Bridge) connectToViteHMR() error { dialer := websocket.Dialer{ Subprotocols: []string{viteHMRProtocol}, } @@ -284,7 +288,7 @@ func (vb *ViteBridge) connectToViteHMR() error { // tunnelKeepalive sends periodic pings to keep the connection alive. // Remote servers often have 30-60s idle timeouts. // The done channel is used to stop this goroutine on reconnect. -func (vb *ViteBridge) tunnelKeepalive(done <-chan struct{}) { +func (vb *Bridge) tunnelKeepalive(done <-chan struct{}) { ticker := time.NewTicker(wsKeepaliveInterval) defer ticker.Stop() @@ -312,7 +316,7 @@ func (vb *ViteBridge) tunnelKeepalive(done <-chan struct{}) { // tunnelWriter handles all writes to the tunnel websocket in a single goroutine // This eliminates mutex contention and ensures ordered delivery -func (vb *ViteBridge) tunnelWriter(ctx context.Context) error { +func (vb *Bridge) tunnelWriter(ctx context.Context) error { for { select { case <-ctx.Done(): @@ -328,7 +332,7 @@ func (vb *ViteBridge) tunnelWriter(ctx context.Context) error { } } -func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { +func (vb *Bridge) handleTunnelMessages(ctx context.Context) error { for { select { case <-ctx.Done(): @@ -348,7 +352,7 @@ func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { return fmt.Errorf("failed to get app domain for reconnection: %w", err) } - if err := vb.connectToTunnelWithRetry(appDomain); err != nil { + if err := vb.ConnectToTunnelWithRetry(appDomain); err != nil { return fmt.Errorf("failed to reconnect to tunnel: %w", err) } continue @@ -359,7 +363,7 @@ func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { // Debug: Log raw message log.Debugf(vb.ctx, "[vite_bridge] Raw message: %s", string(message)) - var msg ViteBridgeMessage + var msg BridgeMessage if err := json.Unmarshal(message, &msg); err != nil { log.Errorf(vb.ctx, "[vite_bridge] Failed to parse message: %v", err) continue @@ -374,7 +378,7 @@ func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { } } -func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { +func (vb *Bridge) handleMessage(msg *BridgeMessage) error { switch msg.Type { case "tunnel:ready": vb.tunnelID = msg.TunnelID @@ -386,7 +390,7 @@ func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { return nil case "fetch": - go func(fetchMsg ViteBridgeMessage) { + go func(fetchMsg BridgeMessage) { if err := vb.handleFetchRequest(&fetchMsg); err != nil { log.Errorf(vb.ctx, "[vite_bridge] Error handling fetch request for %s: %v", fetchMsg.Path, err) } @@ -395,7 +399,7 @@ func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { case "file:read": // Handle file read requests in parallel like fetch requests - go func(fileReadMsg ViteBridgeMessage) { + go func(fileReadMsg BridgeMessage) { if err := vb.handleFileReadRequest(&fileReadMsg); err != nil { log.Errorf(vb.ctx, "[vite_bridge] Error handling file read request for %s: %v", fileReadMsg.Path, err) } @@ -411,7 +415,7 @@ func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { } } -func (vb *ViteBridge) handleConnectionRequest(msg *ViteBridgeMessage) error { +func (vb *Bridge) handleConnectionRequest(msg *BridgeMessage) error { cmdio.LogString(vb.ctx, "") cmdio.LogString(vb.ctx, "šŸ”” Connection Request") cmdio.LogString(vb.ctx, " User: "+msg.Viewer) @@ -437,13 +441,13 @@ func (vb *ViteBridge) handleConnectionRequest(msg *ViteBridgeMessage) error { approved = strings.ToLower(strings.TrimSpace(input)) == "y" case err := <-errChan: return fmt.Errorf("failed to read user input: %w", err) - case <-time.After(bridgeConnTimeout): + case <-time.After(BridgeConnTimeout): // Default to denying after timeout cmdio.LogString(vb.ctx, "ā±ļø Timeout waiting for response, denying connection") approved = false } - response := ViteBridgeMessage{ + response := BridgeMessage{ Type: "connection:response", RequestID: msg.RequestID, Viewer: msg.Viewer, @@ -475,7 +479,7 @@ func (vb *ViteBridge) handleConnectionRequest(msg *ViteBridgeMessage) error { return nil } -func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { +func (vb *Bridge) handleFetchRequest(msg *BridgeMessage) error { targetURL := fmt.Sprintf(localViteURL, vb.port) + msg.Path log.Debugf(vb.ctx, "[vite_bridge] Fetch request: %s %s", msg.Method, msg.Path) @@ -504,7 +508,7 @@ func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { } } - metadataResponse := ViteBridgeMessage{ + metadataResponse := BridgeMessage{ Type: "fetch:response:meta", Path: msg.Path, Status: resp.StatusCode, @@ -523,7 +527,7 @@ func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { data: responseData, priority: 1, // Normal priority }: - case <-time.After(bridgeFetchTimeout): + case <-time.After(BridgeFetchTimeout): return errors.New("timeout sending fetch metadata") } @@ -534,7 +538,7 @@ func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { data: body, priority: 1, // Normal priority }: - case <-time.After(bridgeFetchTimeout): + case <-time.After(BridgeFetchTimeout): return errors.New("timeout sending fetch body") } } @@ -547,17 +551,17 @@ const ( allowedExtension = ".sql" ) -func (vb *ViteBridge) handleFileReadRequest(msg *ViteBridgeMessage) error { +func (vb *Bridge) handleFileReadRequest(msg *BridgeMessage) error { log.Debugf(vb.ctx, "[vite_bridge] File read request: %s", msg.Path) - if err := validateFilePath(msg.Path); err != nil { + if err := ValidateFilePath(msg.Path); err != nil { log.Warnf(vb.ctx, "[vite_bridge] File validation failed for %s: %v", msg.Path, err) return vb.sendFileReadError(msg.RequestID, fmt.Sprintf("Invalid file path: %v", err)) } content, err := os.ReadFile(msg.Path) - response := ViteBridgeMessage{ + response := BridgeMessage{ Type: "file:read:response", RequestID: msg.RequestID, } @@ -588,7 +592,7 @@ func (vb *ViteBridge) handleFileReadRequest(msg *ViteBridgeMessage) error { return nil } -func validateFilePath(requestedPath string) error { +func ValidateFilePath(requestedPath string) error { // Clean the path to resolve any ../ or ./ components cleanPath := filepath.Clean(requestedPath) @@ -628,8 +632,8 @@ func validateFilePath(requestedPath string) error { } // Helper to send error response -func (vb *ViteBridge) sendFileReadError(requestID, errorMsg string) error { - response := ViteBridgeMessage{ +func (vb *Bridge) sendFileReadError(requestID, errorMsg string) error { + response := BridgeMessage{ Type: "file:read:response", RequestID: requestID, Error: errorMsg, @@ -653,10 +657,10 @@ func (vb *ViteBridge) sendFileReadError(requestID, errorMsg string) error { return nil } -func (vb *ViteBridge) handleHMRMessage(msg *ViteBridgeMessage) error { +func (vb *Bridge) handleHMRMessage(msg *BridgeMessage) error { log.Debugf(vb.ctx, "[vite_bridge] HMR message received: %s", msg.Body) - response := ViteBridgeMessage{ + response := BridgeMessage{ Type: "hmr:client", Body: msg.Body, } @@ -680,7 +684,7 @@ func (vb *ViteBridge) handleHMRMessage(msg *ViteBridgeMessage) error { return nil } -func (vb *ViteBridge) handleViteHMRMessages(ctx context.Context) error { +func (vb *Bridge) handleViteHMRMessages(ctx context.Context) error { for { select { case <-ctx.Done(): @@ -703,7 +707,7 @@ func (vb *ViteBridge) handleViteHMRMessages(ctx context.Context) error { return err } - response := ViteBridgeMessage{ + response := BridgeMessage{ Type: "hmr:message", Body: string(message), } @@ -726,14 +730,14 @@ func (vb *ViteBridge) handleViteHMRMessages(ctx context.Context) error { } } -func (vb *ViteBridge) Start() error { +func (vb *Bridge) Start() error { appDomain, err := vb.GetAppDomain() if err != nil { return fmt.Errorf("failed to get app domain: %w", err) } // Use retry logic for initial connection (app may not be ready yet) - if err := vb.connectToTunnelWithRetry(appDomain); err != nil { + if err := vb.ConnectToTunnelWithRetry(appDomain); err != nil { return err } @@ -746,7 +750,7 @@ func (vb *ViteBridge) Start() error { return } - var msg ViteBridgeMessage + var msg BridgeMessage if err := json.Unmarshal(message, &msg); err != nil { continue } @@ -765,7 +769,7 @@ func (vb *ViteBridge) Start() error { if err != nil { return fmt.Errorf("failed waiting for tunnel ready: %w", err) } - case <-time.After(bridgeTunnelReadyTimeout): + case <-time.After(BridgeTunnelReadyTimeout): return errors.New("timeout waiting for tunnel ready") } @@ -821,7 +825,7 @@ func (vb *ViteBridge) Start() error { return g.Wait() } -func (vb *ViteBridge) Stop() { +func (vb *Bridge) Stop() { vb.stopOnce.Do(func() { close(vb.stopChan) diff --git a/experimental/dev/cmd/app/vite_bridge_test.go b/experimental/dev/lib/vite/bridge_test.go similarity index 89% rename from experimental/dev/cmd/app/vite_bridge_test.go rename to experimental/dev/lib/vite/bridge_test.go index 45f758563c..60f0aecf00 100644 --- a/experimental/dev/cmd/app/vite_bridge_test.go +++ b/experimental/dev/lib/vite/bridge_test.go @@ -1,4 +1,4 @@ -package app +package vite import ( "context" @@ -83,7 +83,7 @@ func TestValidateFilePath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateFilePath(tt.path) + err := ValidateFilePath(tt.path) if tt.expectError { require.Error(t, err) assert.Contains(t, err.Error(), tt.errorMsg) @@ -94,21 +94,21 @@ func TestValidateFilePath(t *testing.T) { } } -func TestViteBridgeMessageSerialization(t *testing.T) { +func TestBridgeMessageSerialization(t *testing.T) { tests := []struct { name string - msg ViteBridgeMessage + msg BridgeMessage }{ { name: "tunnel ready message", - msg: ViteBridgeMessage{ + msg: BridgeMessage{ Type: "tunnel:ready", TunnelID: "test-tunnel-123", }, }, { name: "fetch request message", - msg: ViteBridgeMessage{ + msg: BridgeMessage{ Type: "fetch", Path: "/src/components/ui/card.tsx", Method: "GET", @@ -117,7 +117,7 @@ func TestViteBridgeMessageSerialization(t *testing.T) { }, { name: "connection request message", - msg: ViteBridgeMessage{ + msg: BridgeMessage{ Type: "connection:request", Viewer: "user@example.com", RequestID: "req-456", @@ -125,7 +125,7 @@ func TestViteBridgeMessageSerialization(t *testing.T) { }, { name: "fetch response with headers", - msg: ViteBridgeMessage{ + msg: BridgeMessage{ Type: "fetch:response:meta", Status: 200, Headers: map[string]any{ @@ -141,7 +141,7 @@ func TestViteBridgeMessageSerialization(t *testing.T) { data, err := json.Marshal(tt.msg) require.NoError(t, err) - var decoded ViteBridgeMessage + var decoded BridgeMessage err = json.Unmarshal(data, &decoded) require.NoError(t, err) @@ -154,21 +154,21 @@ func TestViteBridgeMessageSerialization(t *testing.T) { } } -func TestViteBridgeHandleMessage(t *testing.T) { +func TestBridgeHandleMessage(t *testing.T) { ctx := cmdio.MockDiscard(context.Background()) w := &databricks.WorkspaceClient{} - vb := NewViteBridge(ctx, w, "test-app", 5173) + vb := NewBridge(ctx, w, "test-app", 5173) tests := []struct { name string - msg *ViteBridgeMessage + msg *BridgeMessage expectError bool }{ { name: "tunnel ready message", - msg: &ViteBridgeMessage{ + msg: &BridgeMessage{ Type: "tunnel:ready", TunnelID: "tunnel-123", }, @@ -176,7 +176,7 @@ func TestViteBridgeHandleMessage(t *testing.T) { }, { name: "unknown message type", - msg: &ViteBridgeMessage{ + msg: &BridgeMessage{ Type: "unknown:type", }, expectError: false, @@ -199,7 +199,7 @@ func TestViteBridgeHandleMessage(t *testing.T) { } } -func TestViteBridgeHandleFileReadRequest(t *testing.T) { +func TestBridgeHandleFileReadRequest(t *testing.T) { // Create a temporary directory structure tmpDir := t.TempDir() oldWd, err := os.Getwd() @@ -250,12 +250,12 @@ func TestViteBridgeHandleFileReadRequest(t *testing.T) { defer resp.Body.Close() defer conn.Close() - vb := NewViteBridge(ctx, w, "test-app", 5173) + vb := NewBridge(ctx, w, "test-app", 5173) vb.tunnelConn = conn go func() { _ = vb.tunnelWriter(ctx) }() - msg := &ViteBridgeMessage{ + msg := &BridgeMessage{ Type: "file:read", Path: "config/queries/test_query.sql", RequestID: "req-123", @@ -268,7 +268,7 @@ func TestViteBridgeHandleFileReadRequest(t *testing.T) { time.Sleep(100 * time.Millisecond) // Parse the response - var response ViteBridgeMessage + var response BridgeMessage err = json.Unmarshal(lastMessage, &response) require.NoError(t, err) @@ -307,12 +307,12 @@ func TestViteBridgeHandleFileReadRequest(t *testing.T) { defer resp.Body.Close() defer conn.Close() - vb := NewViteBridge(ctx, w, "test-app", 5173) + vb := NewBridge(ctx, w, "test-app", 5173) vb.tunnelConn = conn go func() { _ = vb.tunnelWriter(ctx) }() - msg := &ViteBridgeMessage{ + msg := &BridgeMessage{ Type: "file:read", Path: "config/queries/nonexistent.sql", RequestID: "req-456", @@ -324,7 +324,7 @@ func TestViteBridgeHandleFileReadRequest(t *testing.T) { // Give the message time to be sent time.Sleep(100 * time.Millisecond) - var response ViteBridgeMessage + var response BridgeMessage err = json.Unmarshal(lastMessage, &response) require.NoError(t, err) @@ -334,11 +334,11 @@ func TestViteBridgeHandleFileReadRequest(t *testing.T) { }) } -func TestViteBridgeStop(t *testing.T) { +func TestBridgeStop(t *testing.T) { ctx := cmdio.MockDiscard(context.Background()) w := &databricks.WorkspaceClient{} - vb := NewViteBridge(ctx, w, "test-app", 5173) + vb := NewBridge(ctx, w, "test-app", 5173) // Call Stop multiple times to ensure it's idempotent vb.Stop() @@ -354,12 +354,12 @@ func TestViteBridgeStop(t *testing.T) { } } -func TestNewViteBridge(t *testing.T) { +func TestNewBridge(t *testing.T) { ctx := context.Background() w := &databricks.WorkspaceClient{} appName := "test-app" - vb := NewViteBridge(ctx, w, appName, 5173) + vb := NewBridge(ctx, w, appName, 5173) assert.NotNil(t, vb) assert.Equal(t, appName, vb.appName) diff --git a/experimental/dev/cmd/app/vite-server.js b/experimental/dev/lib/vite/server.js similarity index 100% rename from experimental/dev/cmd/app/vite-server.js rename to experimental/dev/lib/vite/server.js From 52b47ab279e953a50b31f1d7ea8ce2c97bdf6045 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 15 Jan 2026 19:03:24 +0100 Subject: [PATCH 06/13] chore: fixup --- experimental/dev/cmd/app/deploy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go index 2172718ee1..2ee7e394c9 100644 --- a/experimental/dev/cmd/app/deploy.go +++ b/experimental/dev/cmd/app/deploy.go @@ -124,7 +124,8 @@ func runDeploy(cmd *cobra.Command, force, skipValidation bool) error { log.Infof(ctx, "Running app: %s", appKey) if err := runApp(ctx, b, appKey); err != nil { cmdio.LogString(ctx, "āœ” Deployment succeeded, but failed to start app") - return fmt.Errorf("failed to run app: %w", err) + appName := b.Config.Resources.Apps[appKey].Name + return fmt.Errorf("failed to run app: %w. Run `databricks apps logs %s` to view logs", err, appName) } cmdio.LogString(ctx, "āœ” Deployment complete!") From c22b082cdf586864b529360398a39aab00a8e023 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 15 Jan 2026 19:44:10 +0100 Subject: [PATCH 07/13] chore: fixup --- cmd/apps/apps.go | 19 + cmd/apps/deploy_bundle.go | 225 +++++ .../cmd/app/dev_remote.go => cmd/apps/dev.go | 24 +- cmd/{workspace => }/apps/dev_test.go | 9 +- .../dev/cmd/app => cmd/apps}/import.go | 12 +- .../dev/cmd/app => cmd/apps}/init.go | 24 +- .../dev/cmd/app => cmd/apps}/init_test.go | 4 +- cmd/{workspace => }/apps/logs.go | 0 cmd/{workspace => }/apps/logs_test.go | 0 cmd/{workspace => }/apps/run_local.go | 6 - cmd/experimental/experimental.go | 2 - cmd/workspace/apps/dev.go | 172 ---- cmd/workspace/apps/overrides.go | 45 +- cmd/workspace/apps/vite-server.js | 172 ---- cmd/workspace/apps/vite_bridge.go | 770 ------------------ cmd/workspace/apps/vite_bridge_test.go | 370 --------- experimental/dev/cmd/app/app.go | 24 - experimental/dev/cmd/app/deploy.go | 191 ----- experimental/dev/cmd/app/dev_remote_test.go | 91 --- experimental/dev/cmd/dev.go | 21 - .../lib => libs/apps}/features/features.go | 0 .../apps}/features/features_test.go | 0 .../dev/lib => libs/apps}/prompt/prompt.go | 6 +- .../lib => libs/apps}/prompt/prompt_test.go | 0 .../lib => libs/apps}/validation/nodejs.go | 2 +- .../apps}/validation/validation.go | 0 .../dev/lib => libs/apps}/vite/bridge.go | 0 .../dev/lib => libs/apps}/vite/bridge_test.go | 0 .../dev/lib => libs/apps}/vite/server.js | 0 29 files changed, 320 insertions(+), 1869 deletions(-) create mode 100644 cmd/apps/apps.go create mode 100644 cmd/apps/deploy_bundle.go rename experimental/dev/cmd/app/dev_remote.go => cmd/apps/dev.go (91%) rename cmd/{workspace => }/apps/dev_test.go (89%) rename {experimental/dev/cmd/app => cmd/apps}/import.go (94%) rename {experimental/dev/cmd/app => cmd/apps}/init.go (97%) rename {experimental/dev/cmd/app => cmd/apps}/init_test.go (99%) rename cmd/{workspace => }/apps/logs.go (100%) rename cmd/{workspace => }/apps/logs_test.go (100%) rename cmd/{workspace => }/apps/run_local.go (98%) delete mode 100644 cmd/workspace/apps/dev.go delete mode 100644 cmd/workspace/apps/vite-server.js delete mode 100644 cmd/workspace/apps/vite_bridge.go delete mode 100644 cmd/workspace/apps/vite_bridge_test.go delete mode 100644 experimental/dev/cmd/app/app.go delete mode 100644 experimental/dev/cmd/app/deploy.go delete mode 100644 experimental/dev/cmd/app/dev_remote_test.go delete mode 100644 experimental/dev/cmd/dev.go rename {experimental/dev/lib => libs/apps}/features/features.go (100%) rename {experimental/dev/lib => libs/apps}/features/features_test.go (100%) rename {experimental/dev/lib => libs/apps}/prompt/prompt.go (98%) rename {experimental/dev/lib => libs/apps}/prompt/prompt_test.go (100%) rename {experimental/dev/lib => libs/apps}/validation/nodejs.go (98%) rename {experimental/dev/lib => libs/apps}/validation/validation.go (100%) rename {experimental/dev/lib => libs/apps}/vite/bridge.go (100%) rename {experimental/dev/lib => libs/apps}/vite/bridge_test.go (100%) rename {experimental/dev/lib => libs/apps}/vite/server.js (100%) diff --git a/cmd/apps/apps.go b/cmd/apps/apps.go new file mode 100644 index 0000000000..eb10c8e83b --- /dev/null +++ b/cmd/apps/apps.go @@ -0,0 +1,19 @@ +package apps + +import "github.com/spf13/cobra" + +// ManagementGroupID contains auto-generated CLI commands for Apps API, +// that are separate from main CLI commands defined in Commands. +const ManagementGroupID = "management" + +// Commands returns the list of custom app commands to be added +// to the auto-generated apps command group. +func Commands() []*cobra.Command { + return []*cobra.Command{ + newInitCmd(), + newImportCmd(), + newDevRemoteCmd(), + newLogsCommand(), + newRunLocal(), + } +} diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go new file mode 100644 index 0000000000..1a6dbc32cd --- /dev/null +++ b/cmd/apps/deploy_bundle.go @@ -0,0 +1,225 @@ +package apps + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/resources" + "github.com/databricks/cli/bundle/run" + "github.com/databricks/cli/cmd/bundle/utils" + "github.com/databricks/cli/libs/apps/validation" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/service/apps" + "github.com/spf13/cobra" +) + +// ErrorWrapper is a function type for wrapping deployment errors. +type ErrorWrapper func(cmd *cobra.Command, appName string, err error) error + +// isBundleDirectory checks if the current directory contains a databricks.yml file. +func isBundleDirectory() bool { + _, err := os.Stat("databricks.yml") + return err == nil +} + +// BundleDeployOverrideWithWrapper creates a deploy override function that uses +// the provided error wrapper for API fallback errors. +func BundleDeployOverrideWithWrapper(wrapError ErrorWrapper) func(*cobra.Command, *apps.CreateAppDeploymentRequest) { + return func(deployCmd *cobra.Command, deployReq *apps.CreateAppDeploymentRequest) { + var ( + force bool + skipValidation bool + ) + + deployCmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") + deployCmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") + + // Update the command usage to reflect that APP_NAME is optional when in bundle mode + deployCmd.Use = "deploy [APP_NAME]" + + // Override Args to allow 0 or 1 arguments (bundle mode vs API mode) + deployCmd.Args = func(cmd *cobra.Command, args []string) error { + // In bundle mode, no arguments needed + if isBundleDirectory() { + if len(args) > 0 { + return errors.New("APP_NAME argument is not allowed when deploying from a bundle directory") + } + return nil + } + // In API mode, exactly 1 argument required + if len(args) != 1 { + return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) + } + return nil + } + + originalRunE := deployCmd.RunE + deployCmd.RunE = func(cmd *cobra.Command, args []string) error { + // If we're in a bundle directory, use the enhanced deploy flow + if isBundleDirectory() { + return runBundleDeploy(cmd, force, skipValidation) + } + + // Otherwise, fall back to the original API deploy command + err := originalRunE(cmd, args) + return wrapError(cmd, deployReq.AppName, err) + } + + // Update the help text to explain the dual behavior + deployCmd.Long = `Create an app deployment. + +When run from a directory containing a databricks.yml bundle configuration, +this command runs an enhanced deployment pipeline: +1. Validates the project (build, typecheck, lint for Node.js projects) +2. Deploys the bundle to the workspace +3. Runs the app + +When run from a non-bundle directory, creates an app deployment using the API. + +Arguments: + APP_NAME: The name of the app (required only when not in a bundle directory). + +Examples: + # Deploy from a bundle directory (no app name required) + databricks apps deploy + + # Deploy a specific app using the API + databricks apps deploy my-app + + # Deploy from bundle with validation skip + databricks apps deploy --skip-validation + + # Force deploy (override git branch validation) + databricks apps deploy --force` + } +} + +// runBundleDeploy executes the enhanced deployment flow for bundle directories. +func runBundleDeploy(cmd *cobra.Command, force, skipValidation bool) error { + ctx := cmd.Context() + + // Get current working directory for validation + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Step 1: Validate project (unless skipped) + if !skipValidation { + validator := getProjectValidator(workDir) + if validator != nil { + result, err := validator.Validate(ctx, workDir) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + if !result.Success { + // Show error details + if result.Details != nil { + cmdio.LogString(ctx, result.Details.Error()) + } + return errors.New("validation failed - fix errors before deploying") + } + cmdio.LogString(ctx, "āœ… "+result.Message) + } else { + log.Debugf(ctx, "No validator found for project type, skipping validation") + } + } + + // Step 2: Deploy bundle + cmdio.LogString(ctx, "Deploying bundle...") + b, err := utils.ProcessBundle(cmd, utils.ProcessOptions{ + InitFunc: func(b *bundle.Bundle) { + b.Config.Bundle.Force = force + }, + // Context is already initialized by the workspace command's PreRunE + SkipInitContext: true, + AlwaysPull: true, + FastValidate: true, + Build: true, + Deploy: true, + }) + if err != nil { + return fmt.Errorf("deploy failed: %w", err) + } + log.Infof(ctx, "Deploy completed") + + // Step 3: Detect and run app + appKey, err := detectBundleApp(b) + if err != nil { + return err + } + + log.Infof(ctx, "Running app: %s", appKey) + if err := runBundleApp(ctx, b, appKey); err != nil { + cmdio.LogString(ctx, "āœ” Deployment succeeded, but failed to start app") + appName := b.Config.Resources.Apps[appKey].Name + return fmt.Errorf("failed to run app: %w. Run `databricks apps logs %s` to view logs", err, appName) + } + + cmdio.LogString(ctx, "āœ” Deployment complete!") + return nil +} + +// getProjectValidator returns the appropriate validator based on project type. +// Returns nil if no validator is applicable. +func getProjectValidator(workDir string) validation.Validation { + // Check for Node.js project (package.json exists) + packageJSON := filepath.Join(workDir, "package.json") + if _, err := os.Stat(packageJSON); err == nil { + return &validation.ValidationNodeJs{} + } + return nil +} + +// detectBundleApp finds the single app in the bundle configuration. +func detectBundleApp(b *bundle.Bundle) (string, error) { + bundleApps := b.Config.Resources.Apps + + if len(bundleApps) == 0 { + return "", errors.New("no apps found in bundle configuration") + } + + if len(bundleApps) > 1 { + return "", errors.New("multiple apps found in bundle, cannot auto-detect") + } + + for key := range bundleApps { + return key, nil + } + + return "", errors.New("unexpected error detecting app") +} + +// runBundleApp runs the specified app using the runner interface. +func runBundleApp(ctx context.Context, b *bundle.Bundle, appKey string) error { + ref, err := resources.Lookup(b, appKey, run.IsRunnable) + if err != nil { + return fmt.Errorf("failed to lookup app: %w", err) + } + + runner, err := run.ToRunner(b, ref) + if err != nil { + return fmt.Errorf("failed to create runner: %w", err) + } + + output, err := runner.Run(ctx, &run.Options{}) + if err != nil { + return fmt.Errorf("failed to run app: %w", err) + } + + if output != nil { + resultString, err := output.String() + if err != nil { + return err + } + log.Infof(ctx, "App output: %s", resultString) + } + + return nil +} diff --git a/experimental/dev/cmd/app/dev_remote.go b/cmd/apps/dev.go similarity index 91% rename from experimental/dev/cmd/app/dev_remote.go rename to cmd/apps/dev.go index 35cbbeb49c..c9dcb5c305 100644 --- a/experimental/dev/cmd/app/dev_remote.go +++ b/cmd/apps/dev.go @@ -1,4 +1,4 @@ -package app +package apps import ( "bytes" @@ -18,8 +18,8 @@ import ( "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/experimental/dev/lib/prompt" - "github.com/databricks/cli/experimental/dev/lib/vite" + "github.com/databricks/cli/libs/apps/prompt" + "github.com/databricks/cli/libs/apps/vite" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/spf13/cobra" @@ -63,14 +63,14 @@ func detectAppNameFromBundle() string { } // Check for apps in the bundle - apps := rootConfig.Resources.Apps - if len(apps) == 0 { + bundleApps := rootConfig.Resources.Apps + if len(bundleApps) == 0 { return "" } // If there's exactly one app, return its name - if len(apps) == 1 { - for _, app := range apps { + if len(bundleApps) == 1 { + for _, app := range bundleApps { return app.Name } } @@ -135,16 +135,16 @@ to the remote Databricks app for development with hot module replacement. Examples: # Interactive mode - select app from picker - databricks experimental dev app dev-remote + databricks apps dev-remote # Start development server for a specific app - databricks experimental dev app dev-remote --name my-app + databricks apps dev-remote --name my-app # Use a custom client path - databricks experimental dev app dev-remote --name my-app --client-path ./frontend + databricks apps dev-remote --name my-app --client-path ./frontend # Use a custom port - databricks experimental dev app dev-remote --name my-app --port 3000`, + databricks apps dev-remote --name my-app --port 3000`, Args: root.NoArgs, PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { @@ -191,7 +191,7 @@ Examples: }) if err != nil { if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "is deleted") { - return fmt.Errorf("application '%s' has not been deployed yet. Run `databricks experimental dev app deploy` to deploy and then try again", appName) + return fmt.Errorf("application '%s' has not been deployed yet. Run `databricks apps deploy` to deploy and then try again", appName) } return fmt.Errorf("failed to get app domain: %w", err) } diff --git a/cmd/workspace/apps/dev_test.go b/cmd/apps/dev_test.go similarity index 89% rename from cmd/workspace/apps/dev_test.go rename to cmd/apps/dev_test.go index 2133d0440f..8aa0224798 100644 --- a/cmd/workspace/apps/dev_test.go +++ b/cmd/apps/dev_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/databricks/cli/libs/apps/vite" "github.com/databricks/cli/libs/cmdio" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,10 +47,10 @@ func TestIsViteReady(t *testing.T) { func TestViteServerScriptContent(t *testing.T) { // Verify the embedded script is not empty - assert.NotEmpty(t, viteServerScript) + assert.NotEmpty(t, vite.ServerScript) // Verify it's a JavaScript file with expected content - assert.Contains(t, string(viteServerScript), "startViteServer") + assert.Contains(t, string(vite.ServerScript), "startViteServer") } func TestStartViteDevServerNoNode(t *testing.T) { @@ -81,9 +82,9 @@ func TestStartViteDevServerNoNode(t *testing.T) { } func TestViteServerScriptEmbedded(t *testing.T) { - assert.NotEmpty(t, viteServerScript) + assert.NotEmpty(t, vite.ServerScript) - scriptContent := string(viteServerScript) + scriptContent := string(vite.ServerScript) assert.Contains(t, scriptContent, "startViteServer") assert.Contains(t, scriptContent, "createServer") assert.Contains(t, scriptContent, "queriesHMRPlugin") diff --git a/experimental/dev/cmd/app/import.go b/cmd/apps/import.go similarity index 94% rename from experimental/dev/cmd/app/import.go rename to cmd/apps/import.go index 8010c274b6..d40f2e788e 100644 --- a/experimental/dev/cmd/app/import.go +++ b/cmd/apps/import.go @@ -1,4 +1,4 @@ -package app +package apps import ( "context" @@ -10,7 +10,7 @@ import ( "github.com/databricks/cli/bundle/generate" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/experimental/dev/lib/prompt" + "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/service/apps" @@ -34,16 +34,16 @@ named after the app. Examples: # Interactive mode - select app from picker - databricks experimental dev app import + databricks apps import # Import a specific app's source code - databricks experimental dev app import --name my-app + databricks apps import --name my-app # Import to a specific directory - databricks experimental dev app import --name my-app --output-dir ./projects + databricks apps import --name my-app --output-dir ./projects # Force overwrite existing files - databricks experimental dev app import --name my-app --force`, + databricks apps import --name my-app --force`, Args: root.NoArgs, PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/experimental/dev/cmd/app/init.go b/cmd/apps/init.go similarity index 97% rename from experimental/dev/cmd/app/init.go rename to cmd/apps/init.go index 26595ed89d..ea6c1957b5 100644 --- a/experimental/dev/cmd/app/init.go +++ b/cmd/apps/init.go @@ -1,4 +1,4 @@ -package app +package apps import ( "bytes" @@ -13,8 +13,8 @@ import ( "github.com/charmbracelet/huh" "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/experimental/dev/lib/features" - "github.com/databricks/cli/experimental/dev/lib/prompt" + "github.com/databricks/cli/libs/apps/features" + "github.com/databricks/cli/libs/apps/prompt" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" @@ -50,22 +50,22 @@ guides you through the setup. When run with --name, runs in non-interactive mode Examples: # Interactive mode with default template (recommended) - databricks experimental dev app init + databricks apps init # Non-interactive with flags - databricks experimental dev app init --name my-app + databricks apps init --name my-app # With analytics feature (requires --warehouse-id) - databricks experimental dev app init --name my-app --features=analytics --warehouse-id=abc123 + databricks apps init --name my-app --features=analytics --warehouse-id=abc123 # Create, deploy, and run with dev-remote - databricks experimental dev app init --name my-app --deploy --run=dev-remote + databricks apps init --name my-app --deploy --run=dev-remote # With a custom template from a local path - databricks experimental dev app init --template /path/to/template --name my-app + databricks apps init --template /path/to/template --name my-app # With a GitHub URL - databricks experimental dev app init --template https://github.com/user/repo --name my-app + databricks apps init --template https://github.com/user/repo --name my-app Feature dependencies: Some features require additional flags: @@ -711,7 +711,7 @@ func runCreate(ctx context.Context, opts createOptions) error { cmdio.LogString(ctx, "Deploying app...") if err := runPostCreateDeploy(ctx); err != nil { cmdio.LogString(ctx, fmt.Sprintf("⚠ Deploy failed: %v", err)) - cmdio.LogString(ctx, " You can deploy manually with: databricks experimental dev app deploy") + cmdio.LogString(ctx, " You can deploy manually with: databricks apps deploy") } } @@ -729,7 +729,7 @@ func runCreate(ctx context.Context, opts createOptions) error { func runPostCreateDeploy(ctx context.Context) error { // Use os.Args[0] to get the path to the current executable executable := os.Args[0] - cmd := exec.CommandContext(ctx, executable, "experimental", "dev", "app", "deploy") + cmd := exec.CommandContext(ctx, executable, "apps", "deploy") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -750,7 +750,7 @@ func runPostCreateDev(ctx context.Context, mode prompt.RunMode) error { cmdio.LogString(ctx, "Starting remote development server...") // Use os.Args[0] to get the path to the current executable executable := os.Args[0] - cmd := exec.CommandContext(ctx, executable, "experimental", "dev", "app", "dev-remote") + cmd := exec.CommandContext(ctx, executable, "apps", "dev-remote") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin diff --git a/experimental/dev/cmd/app/init_test.go b/cmd/apps/init_test.go similarity index 99% rename from experimental/dev/cmd/app/init_test.go rename to cmd/apps/init_test.go index 7ee6d3e14d..6e889e4f4e 100644 --- a/experimental/dev/cmd/app/init_test.go +++ b/cmd/apps/init_test.go @@ -1,9 +1,9 @@ -package app +package apps import ( "testing" - "github.com/databricks/cli/experimental/dev/lib/prompt" + "github.com/databricks/cli/libs/apps/prompt" "github.com/stretchr/testify/assert" ) diff --git a/cmd/workspace/apps/logs.go b/cmd/apps/logs.go similarity index 100% rename from cmd/workspace/apps/logs.go rename to cmd/apps/logs.go diff --git a/cmd/workspace/apps/logs_test.go b/cmd/apps/logs_test.go similarity index 100% rename from cmd/workspace/apps/logs_test.go rename to cmd/apps/logs_test.go diff --git a/cmd/workspace/apps/run_local.go b/cmd/apps/run_local.go similarity index 98% rename from cmd/workspace/apps/run_local.go rename to cmd/apps/run_local.go index 1d68f84b7e..bb6219609f 100644 --- a/cmd/workspace/apps/run_local.go +++ b/cmd/apps/run_local.go @@ -234,9 +234,3 @@ func newRunLocal() *cobra.Command { return cmd } - -func init() { - cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) { - cmd.AddCommand(newRunLocal()) - }) -} diff --git a/cmd/experimental/experimental.go b/cmd/experimental/experimental.go index b1931b18da..7e7d376fea 100644 --- a/cmd/experimental/experimental.go +++ b/cmd/experimental/experimental.go @@ -2,7 +2,6 @@ package experimental import ( mcp "github.com/databricks/cli/experimental/aitools/cmd" - dev "github.com/databricks/cli/experimental/dev/cmd" "github.com/spf13/cobra" ) @@ -21,7 +20,6 @@ These commands provide early access to new features that are still under development. They may change or be removed in future versions without notice.`, } - cmd.AddCommand(dev.New()) cmd.AddCommand(mcp.NewMcpCmd()) return cmd diff --git a/cmd/workspace/apps/dev.go b/cmd/workspace/apps/dev.go deleted file mode 100644 index 5cad8f8879..0000000000 --- a/cmd/workspace/apps/dev.go +++ /dev/null @@ -1,172 +0,0 @@ -package apps - -import ( - "bytes" - "context" - _ "embed" - "errors" - "fmt" - "net" - "os" - "os/exec" - "os/signal" - "strconv" - "syscall" - "time" - - "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/libs/cmdctx" - "github.com/databricks/cli/libs/cmdio" - "github.com/spf13/cobra" -) - -//go:embed vite-server.js -var viteServerScript []byte - -const ( - vitePort = 5173 - viteReadyCheckInterval = 100 * time.Millisecond - viteReadyMaxAttempts = 50 -) - -func isViteReady(port int) bool { - conn, err := net.DialTimeout("tcp", "localhost:"+strconv.Itoa(port), viteReadyCheckInterval) - if err != nil { - return false - } - conn.Close() - return true -} - -func startViteDevServer(ctx context.Context, appURL string, port int) (*exec.Cmd, chan error, error) { - // Pass script through stdin, and pass arguments in order - viteCmd := exec.Command("node", "-", appURL, strconv.Itoa(port)) - viteCmd.Stdin = bytes.NewReader(viteServerScript) - viteCmd.Stdout = os.Stdout - viteCmd.Stderr = os.Stderr - - err := viteCmd.Start() - if err != nil { - return nil, nil, fmt.Errorf("failed to start Vite server: %w", err) - } - - cmdio.LogString(ctx, fmt.Sprintf("šŸš€ Starting Vite development server on port %d...", port)) - - viteErr := make(chan error, 1) - go func() { - if err := viteCmd.Wait(); err != nil { - viteErr <- fmt.Errorf("vite server exited with error: %w", err) - } else { - viteErr <- errors.New("vite server exited unexpectedly") - } - }() - - for range viteReadyMaxAttempts { - select { - case err := <-viteErr: - return nil, nil, err - default: - if isViteReady(port) { - return viteCmd, viteErr, nil - } - time.Sleep(viteReadyCheckInterval) - } - } - - _ = viteCmd.Process.Kill() - return nil, nil, errors.New("timeout waiting for Vite server to be ready") -} - -func newRunDevCommand() *cobra.Command { - var ( - appName string - clientPath string - port int - ) - - cmd := &cobra.Command{} - - cmd.Use = "dev-remote" - cmd.Hidden = true - cmd.Short = `Run Databricks app locally with WebSocket bridge to remote server.` - cmd.Long = `Run Databricks app locally with WebSocket bridge to remote server. - - Starts a local development server and establishes a WebSocket bridge - to the remote Databricks app for development. - ` - - cmd.PreRunE = root.MustWorkspaceClient - - cmd.Flags().StringVar(&appName, "app-name", "", "Name of the app to connect to (required)") - cmd.Flags().StringVar(&clientPath, "client-path", "./client", "Path to the Vite client directory") - cmd.Flags().IntVar(&port, "port", vitePort, "Port to run the Vite server on") - - cmd.RunE = func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - w := cmdctx.WorkspaceClient(ctx) - - if appName == "" { - return errors.New("app name is required (use --app-name)") - } - - if _, err := os.Stat(clientPath); os.IsNotExist(err) { - return fmt.Errorf("client directory not found: %s", clientPath) - } - - bridge := NewViteBridge(ctx, w, appName, port) - - appDomain, err := bridge.GetAppDomain() - if err != nil { - return fmt.Errorf("failed to get app domain: %w", err) - } - - viteCmd, viteErr, err := startViteDevServer(ctx, appDomain.String(), port) - if err != nil { - return err - } - - done := make(chan error, 1) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - go func() { - done <- bridge.Start() - }() - - select { - case err := <-viteErr: - bridge.Stop() - <-done - return err - case err := <-done: - cmdio.LogString(ctx, "Bridge stopped") - if viteCmd.Process != nil { - _ = viteCmd.Process.Signal(os.Interrupt) - <-viteErr - } - return err - case <-sigChan: - cmdio.LogString(ctx, "\nšŸ›‘ Shutting down...") - bridge.Stop() - <-done - if viteCmd.Process != nil { - if err := viteCmd.Process.Signal(os.Interrupt); err != nil { - cmdio.LogString(ctx, fmt.Sprintf("Failed to interrupt Vite: %v", err)) - _ = viteCmd.Process.Kill() - } - <-viteErr - } - return nil - } - } - - cmd.ValidArgsFunction = cobra.NoFileCompletions - - return cmd -} - -func init() { - cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) { - cmd.AddCommand(newRunDevCommand()) - }) -} diff --git a/cmd/workspace/apps/overrides.go b/cmd/workspace/apps/overrides.go index 34a9cd8ee9..f678cc3045 100644 --- a/cmd/workspace/apps/overrides.go +++ b/cmd/workspace/apps/overrides.go @@ -1,6 +1,9 @@ package apps import ( + "slices" + + appsCli "github.com/databricks/cli/cmd/apps" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/service/apps" "github.com/spf13/cobra" @@ -33,14 +36,6 @@ func createOverride(createCmd *cobra.Command, createReq *apps.CreateAppRequest) } } -func deployOverride(deployCmd *cobra.Command, deployReq *apps.CreateAppDeploymentRequest) { - originalRunE := deployCmd.RunE - deployCmd.RunE = func(cmd *cobra.Command, args []string) error { - err := originalRunE(cmd, args) - return wrapDeploymentError(cmd, deployReq.AppName, err) - } -} - func createUpdateOverride(createUpdateCmd *cobra.Command, createUpdateReq *apps.AsyncUpdateAppRequest) { originalRunE := createUpdateCmd.RunE createUpdateCmd.RunE = func(cmd *cobra.Command, args []string) error { @@ -59,12 +54,42 @@ func startOverride(startCmd *cobra.Command, startReq *apps.StartAppRequest) { func init() { cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) { - cmd.AddCommand(newLogsCommand()) + // Commands that should NOT go into the management group + // (either they are main commands or have special grouping) + nonManagementCommands := []string{ + // 'deploy' is overloaded as API and bundle command + "deploy", + // permission commands are assigned into "permission" group in cmd/cmd.go + "get-permission-levels", + "get-permissions", + "set-permissions", + "update-permissions", + } + + // Put auto-generated API commands into 'management' group + for _, subCmd := range cmd.Commands() { + if slices.Contains(nonManagementCommands, subCmd.Name()) { + continue + } + if subCmd.GroupID == "" { + subCmd.GroupID = appsCli.ManagementGroupID + } + } + + // Add custom commands from cmd/apps/ + for _, appsCmd := range appsCli.Commands() { + cmd.AddCommand(appsCmd) + } + + // Add --var flag support for bundle operations + cmd.PersistentFlags().StringSlice("var", []string{}, `set values for variables defined in bundle config. Example: --var="key=value"`) }) + + // Register command overrides listOverrides = append(listOverrides, listOverride) listDeploymentsOverrides = append(listDeploymentsOverrides, listDeploymentsOverride) createOverrides = append(createOverrides, createOverride) - deployOverrides = append(deployOverrides, deployOverride) + deployOverrides = append(deployOverrides, appsCli.BundleDeployOverrideWithWrapper(wrapDeploymentError)) createUpdateOverrides = append(createUpdateOverrides, createUpdateOverride) startOverrides = append(startOverrides, startOverride) } diff --git a/cmd/workspace/apps/vite-server.js b/cmd/workspace/apps/vite-server.js deleted file mode 100644 index e0ba85322a..0000000000 --- a/cmd/workspace/apps/vite-server.js +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env node -const path = require("node:path"); -const fs = require("node:fs"); - -async function startViteServer() { - const vitePath = safeViteResolve(); - - if (!vitePath) { - console.log( - "\nāŒ Vite needs to be installed in the current directory. Run `npm install vite`.\n" - ); - process.exit(1); - } - - const { createServer, loadConfigFromFile, mergeConfig } = require(vitePath); - - /** - * This script is controlled by us, and shouldn't be called directly by the user. - * We know the order of the arguments is always: - * 1. appUrl - * 2. port - * - * We can safely access the arguments by index. - */ - const clientPath = path.join(process.cwd(), "client"); - const appUrl = process.argv[2] || ""; - const port = parseInt(process.argv[3] || 5173); - - if (!fs.existsSync(clientPath)) { - console.error("client folder doesn't exist."); - process.exit(1); - } - - if (!appUrl) { - console.error("App URL is required"); - process.exit(1); - } - - try { - const domain = new URL(appUrl); - - const loadedConfig = await loadConfigFromFile( - { - mode: "development", - command: "serve", - }, - undefined, - clientPath - ); - const userConfig = loadedConfig?.config ?? {}; - - /** - * Vite uses the same port for the HMR server as the main server. - * Allowing the user to set this option breaks the system. - * By just providing the port override option, Vite will use the same port for the HMR server. - * Multiple servers will work, but if the user has this in their config we need to delete it. - */ - delete userConfig.server?.hmr?.port; - - const coreConfig = { - configFile: false, - root: clientPath, - server: { - open: `${domain.origin}?dev=true`, - port: port, - hmr: { - overlay: true, - path: `/dev-hmr`, - }, - middlewareMode: false, - }, - plugins: [queriesHMRPlugin()], - }; - const mergedConfigs = mergeConfig(userConfig, coreConfig); - const server = await createServer(mergedConfigs); - - await server.listen(); - - console.log(`\nāœ… Vite dev server started successfully!`); - console.log(`\nPress Ctrl+C to stop the server\n`); - - const shutdown = async () => { - await server.close(); - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - } catch (error) { - console.error(`āŒ Failed to start Vite server:`, error.message); - if (error.stack) { - console.error(error.stack); - } - process.exit(1); - } -} - -function safeViteResolve() { - try { - const vitePath = require.resolve("vite", { paths: [process.cwd()] }); - - return vitePath; - } catch (error) { - return null; - } -} - -// Start the server -startViteServer().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -}); - -/* - * development only, watches for changes in the queries directory and sends HMR updates to the client. - */ -function queriesHMRPlugin(options = {}) { - const { queriesPath = path.resolve(process.cwd(), "config/queries") } = - options; - let isServe = false; - let serverRunning = false; - - return { - name: "queries-hmr", - async buildStart() { - if (!isServe) return; - if (serverRunning) { - return; - } - serverRunning = true; - }, - configResolved(config) { - isServe = config.command === "serve"; - }, - configureServer(server) { - if (!isServe) return; - if (!server.config.mode || server.config.mode === "development") { - // 1. check if queries directory exists - if (fs.existsSync(queriesPath)) { - // 2. add the queries directory to the watcher - server.watcher.add(queriesPath); - - const handleFileChange = (file) => { - if (file.includes("config/queries") && file.endsWith(".sql")) { - const fileName = path.basename(file); - const queryKey = fileName.replace(/\.(sql)$/, ""); - - console.log("šŸ”„ Query updated:", queryKey, fileName); - - server.ws.send({ - type: "custom", - event: "query-update", - data: { - key: queryKey, - timestamp: Date.now(), - }, - }); - } - }; - - server.watcher.on("change", handleFileChange); - } - - process.on("SIGINT", () => { - console.log("šŸ›‘ SIGINT received — cleaning up before exit..."); - serverRunning = false; - process.exit(0); - }); - } - }, - }; -} diff --git a/cmd/workspace/apps/vite_bridge.go b/cmd/workspace/apps/vite_bridge.go deleted file mode 100644 index 288d6b46d8..0000000000 --- a/cmd/workspace/apps/vite_bridge.go +++ /dev/null @@ -1,770 +0,0 @@ -package apps - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/log" - "github.com/databricks/databricks-sdk-go" - "github.com/databricks/databricks-sdk-go/service/apps" - "github.com/gorilla/websocket" - "golang.org/x/sync/errgroup" -) - -const ( - localViteURL = "http://localhost:%d" - localViteHMRURL = "ws://localhost:%d/dev-hmr" - viteHMRProtocol = "vite-hmr" - - // WebSocket timeouts - wsHandshakeTimeout = 45 * time.Second - wsKeepaliveInterval = 20 * time.Second - wsWriteTimeout = 5 * time.Second - - // HTTP client timeouts - httpRequestTimeout = 60 * time.Second - httpIdleConnTimeout = 90 * time.Second - - // Bridge operation timeouts - bridgeFetchTimeout = 30 * time.Second - bridgeConnTimeout = 60 * time.Second - bridgeTunnelReadyTimeout = 30 * time.Second -) - -type ViteBridgeMessage struct { - Type string `json:"type"` - TunnelID string `json:"tunnelId,omitempty"` - Path string `json:"path,omitempty"` - Method string `json:"method,omitempty"` - Status int `json:"status,omitempty"` - Headers map[string]any `json:"headers,omitempty"` - Body string `json:"body,omitempty"` - Viewer string `json:"viewer"` - RequestID string `json:"requestId"` - Approved bool `json:"approved"` - Content string `json:"content,omitempty"` - Error string `json:"error,omitempty"` -} - -// prioritizedMessage represents a message to send through the tunnel websocket -type prioritizedMessage struct { - messageType int - data []byte - priority int // 0 = high (HMR), 1 = normal (fetch) -} - -type ViteBridge struct { - ctx context.Context - w *databricks.WorkspaceClient - appName string - tunnelConn *websocket.Conn - hmrConn *websocket.Conn - tunnelID string - tunnelWriteChan chan prioritizedMessage - stopChan chan struct{} - stopOnce sync.Once - httpClient *http.Client - connectionRequests chan *ViteBridgeMessage - port int -} - -func NewViteBridge(ctx context.Context, w *databricks.WorkspaceClient, appName string, port int) *ViteBridge { - // Configure HTTP client optimized for local high-volume requests - transport := &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: httpIdleConnTimeout, - DisableKeepAlives: false, - DisableCompression: false, - } - - return &ViteBridge{ - ctx: ctx, - w: w, - appName: appName, - httpClient: &http.Client{ - Timeout: httpRequestTimeout, - Transport: transport, - }, - stopChan: make(chan struct{}), - tunnelWriteChan: make(chan prioritizedMessage, 100), // Buffered channel for async writes - connectionRequests: make(chan *ViteBridgeMessage, 10), - port: port, - } -} - -func (vb *ViteBridge) getAuthHeaders(wsURL string) (http.Header, error) { - req, err := http.NewRequestWithContext(vb.ctx, "GET", wsURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - err = vb.w.Config.Authenticate(req) - if err != nil { - return nil, fmt.Errorf("failed to authenticate: %w", err) - } - - return req.Header, nil -} - -func (vb *ViteBridge) GetAppDomain() (*url.URL, error) { - app, err := vb.w.Apps.Get(vb.ctx, apps.GetAppRequest{ - Name: vb.appName, - }) - if err != nil { - return nil, fmt.Errorf("failed to get app: %w", err) - } - - if app.Url == "" { - return nil, errors.New("app URL is empty") - } - - return url.Parse(app.Url) -} - -func (vb *ViteBridge) connectToTunnel(appDomain *url.URL) error { - wsURL := fmt.Sprintf("wss://%s/dev-tunnel", appDomain.Host) - - headers, err := vb.getAuthHeaders(wsURL) - if err != nil { - return fmt.Errorf("failed to get auth headers: %w", err) - } - - dialer := websocket.Dialer{ - HandshakeTimeout: wsHandshakeTimeout, - ReadBufferSize: 256 * 1024, // 256KB read buffer for large assets - WriteBufferSize: 256 * 1024, // 256KB write buffer for large assets - } - - conn, resp, err := dialer.Dial(wsURL, headers) - if err != nil { - if resp != nil { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return fmt.Errorf("failed to connect to tunnel (status %d): %w, body: %s", resp.StatusCode, err, string(body)) - } - return fmt.Errorf("failed to connect to tunnel: %w", err) - } - if resp != nil && resp.Body != nil { - resp.Body.Close() - } - - // Configure keepalive to prevent server timeout - _ = conn.SetReadDeadline(time.Time{}) // No read timeout - _ = conn.SetWriteDeadline(time.Time{}) // No write timeout - - // Enable pong handler to respond to server pongs (response to our pings) - conn.SetPongHandler(func(appData string) error { - log.Debugf(vb.ctx, "[vite_bridge] Received pong from server") - return nil - }) - - // Enable ping handler to respond to server pings with pongs - conn.SetPingHandler(func(appData string) error { - log.Debugf(vb.ctx, "[vite_bridge] Received ping from server, sending pong") - // Send pong response - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.PongMessage, - data: []byte(appData), - priority: 0, // High priority - }: - case <-time.After(wsWriteTimeout): - log.Warnf(vb.ctx, "[vite_bridge] Failed to send pong response") - } - return nil - }) - - vb.tunnelConn = conn - - // Start keepalive ping goroutine - go vb.tunnelKeepalive() - - return nil -} - -func (vb *ViteBridge) connectToViteHMR() error { - dialer := websocket.Dialer{ - Subprotocols: []string{viteHMRProtocol}, - } - - conn, resp, err := dialer.Dial(fmt.Sprintf(localViteHMRURL, vb.port), nil) - if err != nil { - if resp != nil && resp.Body != nil { - resp.Body.Close() - } - return fmt.Errorf("failed to connect to Vite HMR: %w", err) - } - if resp != nil && resp.Body != nil { - resp.Body.Close() - } - - vb.hmrConn = conn - log.Infof(vb.ctx, "[vite_bridge] Connected to local Vite HMR WS") - return nil -} - -// tunnelKeepalive sends periodic pings to keep the connection alive -// Remote servers often have 30-60s idle timeouts -func (vb *ViteBridge) tunnelKeepalive() { - ticker := time.NewTicker(wsKeepaliveInterval) - defer ticker.Stop() - - for { - select { - case <-vb.stopChan: - return - case <-ticker.C: - // Send ping through the write channel to avoid race conditions - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.PingMessage, - data: []byte{}, - priority: 0, // High priority to ensure keepalive - }: - log.Debugf(vb.ctx, "[vite_bridge] Sent keepalive ping") - case <-time.After(wsWriteTimeout): - log.Warnf(vb.ctx, "[vite_bridge] Failed to send keepalive ping (channel full)") - } - } - } -} - -// tunnelWriter handles all writes to the tunnel websocket in a single goroutine -// This eliminates mutex contention and ensures ordered delivery -func (vb *ViteBridge) tunnelWriter(ctx context.Context) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-vb.stopChan: - return nil - case msg := <-vb.tunnelWriteChan: - if err := vb.tunnelConn.WriteMessage(msg.messageType, msg.data); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Failed to write message: %v", err) - return fmt.Errorf("failed to write to tunnel: %w", err) - } - } - } -} - -func (vb *ViteBridge) handleTunnelMessages(ctx context.Context) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-vb.stopChan: - return nil - default: - } - - _, message, err := vb.tunnelConn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) { - log.Infof(vb.ctx, "[vite_bridge] Tunnel closed, reconnecting...") - time.Sleep(time.Second) - - appDomain, err := vb.GetAppDomain() - if err != nil { - return fmt.Errorf("failed to get app domain for reconnection: %w", err) - } - - if err := vb.connectToTunnel(appDomain); err != nil { - return fmt.Errorf("failed to reconnect to tunnel: %w", err) - } - continue - } - return fmt.Errorf("tunnel connection error: %w", err) - } - - // Debug: Log raw message - log.Debugf(vb.ctx, "[vite_bridge] Raw message: %s", string(message)) - - var msg ViteBridgeMessage - if err := json.Unmarshal(message, &msg); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Failed to parse message: %v", err) - continue - } - - // Debug: Log all incoming message types - log.Debugf(vb.ctx, "[vite_bridge] Received message type: %s", msg.Type) - - if err := vb.handleMessage(&msg); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Error handling message: %v", err) - } - } -} - -func (vb *ViteBridge) handleMessage(msg *ViteBridgeMessage) error { - switch msg.Type { - case "tunnel:ready": - vb.tunnelID = msg.TunnelID - log.Infof(vb.ctx, "[vite_bridge] Tunnel ID assigned: %s", vb.tunnelID) - return nil - - case "connection:request": - vb.connectionRequests <- msg - return nil - - case "fetch": - go func(fetchMsg ViteBridgeMessage) { - if err := vb.handleFetchRequest(&fetchMsg); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Error handling fetch request for %s: %v", fetchMsg.Path, err) - } - }(*msg) - return nil - - case "file:read": - // Handle file read requests in parallel like fetch requests - go func(fileReadMsg ViteBridgeMessage) { - if err := vb.handleFileReadRequest(&fileReadMsg); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Error handling file read request for %s: %v", fileReadMsg.Path, err) - } - }(*msg) - return nil - - case "hmr:message": - return vb.handleHMRMessage(msg) - - default: - log.Warnf(vb.ctx, "[vite_bridge] Unknown message type: %s", msg.Type) - return nil - } -} - -func (vb *ViteBridge) handleConnectionRequest(msg *ViteBridgeMessage) error { - cmdio.LogString(vb.ctx, "") - cmdio.LogString(vb.ctx, "šŸ”” Connection Request") - cmdio.LogString(vb.ctx, " User: "+msg.Viewer) - cmdio.LogString(vb.ctx, " Approve this connection? (y/n)") - - // Read from stdin with timeout to prevent indefinite blocking - inputChan := make(chan string, 1) - errChan := make(chan error, 1) - - go func() { - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - errChan <- err - return - } - inputChan <- input - }() - - var approved bool - select { - case input := <-inputChan: - approved = strings.ToLower(strings.TrimSpace(input)) == "y" - case err := <-errChan: - return fmt.Errorf("failed to read user input: %w", err) - case <-time.After(bridgeConnTimeout): - // Default to denying after timeout - cmdio.LogString(vb.ctx, "ā±ļø Timeout waiting for response, denying connection") - approved = false - } - - response := ViteBridgeMessage{ - Type: "connection:response", - RequestID: msg.RequestID, - Viewer: msg.Viewer, - Approved: approved, - } - - responseData, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal connection response: %w", err) - } - - // Send through channel instead of direct write - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 1, - }: - case <-time.After(wsWriteTimeout): - return errors.New("timeout sending connection response") - } - - if approved { - cmdio.LogString(vb.ctx, "āœ… Approved connection from "+msg.Viewer) - } else { - cmdio.LogString(vb.ctx, "āŒ Denied connection from "+msg.Viewer) - } - - return nil -} - -func (vb *ViteBridge) handleFetchRequest(msg *ViteBridgeMessage) error { - targetURL := fmt.Sprintf(localViteURL, vb.port) + msg.Path - log.Debugf(vb.ctx, "[vite_bridge] Fetch request: %s %s", msg.Method, msg.Path) - - req, err := http.NewRequestWithContext(vb.ctx, msg.Method, targetURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := vb.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch from Vite: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - log.Debugf(vb.ctx, "[vite_bridge] Fetch response: %s (status=%d, size=%d bytes)", msg.Path, resp.StatusCode, len(body)) - - headers := make(map[string]any, len(resp.Header)) - for key, values := range resp.Header { - if len(values) > 0 { - headers[key] = values[0] - } - } - - metadataResponse := ViteBridgeMessage{ - Type: "fetch:response:meta", - Path: msg.Path, - Status: resp.StatusCode, - Headers: headers, - RequestID: msg.RequestID, - } - - responseData, err := json.Marshal(metadataResponse) - if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) - } - - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 1, // Normal priority - }: - case <-time.After(bridgeFetchTimeout): - return errors.New("timeout sending fetch metadata") - } - - if len(body) > 0 { - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.BinaryMessage, - data: body, - priority: 1, // Normal priority - }: - case <-time.After(bridgeFetchTimeout): - return errors.New("timeout sending fetch body") - } - } - - return nil -} - -const ( - allowedBasePath = "config/queries" - allowedExtension = ".sql" -) - -func (vb *ViteBridge) handleFileReadRequest(msg *ViteBridgeMessage) error { - log.Debugf(vb.ctx, "[vite_bridge] File read request: %s", msg.Path) - - if err := validateFilePath(msg.Path); err != nil { - log.Warnf(vb.ctx, "[vite_bridge] File validation failed for %s: %v", msg.Path, err) - return vb.sendFileReadError(msg.RequestID, fmt.Sprintf("Invalid file path: %v", err)) - } - - content, err := os.ReadFile(msg.Path) - - response := ViteBridgeMessage{ - Type: "file:read:response", - RequestID: msg.RequestID, - } - - if err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Failed to read file %s: %v", msg.Path, err) - response.Error = err.Error() - } else { - log.Debugf(vb.ctx, "[vite_bridge] Read file %s (%d bytes)", msg.Path, len(content)) - response.Content = string(content) - } - - responseData, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal file read response: %w", err) - } - - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 1, - }: - case <-time.After(wsWriteTimeout): - return errors.New("timeout sending file read response") - } - - return nil -} - -func validateFilePath(requestedPath string) error { - // Clean the path to resolve any ../ or ./ components - cleanPath := filepath.Clean(requestedPath) - - // Get absolute path - absPath, err := filepath.Abs(cleanPath) - if err != nil { - return fmt.Errorf("failed to resolve absolute path: %w", err) - } - - // Get the working directory - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Construct the allowed base directory (absolute path) - allowedDir := filepath.Join(cwd, allowedBasePath) - - // Ensure the resolved path is within the allowed directory - // Add trailing separator to prevent prefix attacks (e.g., queries-malicious/) - allowedDirWithSep := allowedDir + string(filepath.Separator) - if absPath != allowedDir && !strings.HasPrefix(absPath, allowedDirWithSep) { - return fmt.Errorf("path %s is outside allowed directory %s", absPath, allowedBasePath) - } - - // Ensure the file has the correct extension - if filepath.Ext(absPath) != allowedExtension { - return fmt.Errorf("only %s files are allowed, got: %s", allowedExtension, filepath.Ext(absPath)) - } - - // Additional check: no hidden files - if strings.HasPrefix(filepath.Base(absPath), ".") { - return errors.New("hidden files are not allowed") - } - - return nil -} - -// Helper to send error response -func (vb *ViteBridge) sendFileReadError(requestID, errorMsg string) error { - response := ViteBridgeMessage{ - Type: "file:read:response", - RequestID: requestID, - Error: errorMsg, - } - - responseData, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal error response: %w", err) - } - - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 1, - }: - case <-time.After(wsWriteTimeout): - return errors.New("timeout sending file read error") - } - - return nil -} - -func (vb *ViteBridge) handleHMRMessage(msg *ViteBridgeMessage) error { - log.Debugf(vb.ctx, "[vite_bridge] HMR message received: %s", msg.Body) - - response := ViteBridgeMessage{ - Type: "hmr:client", - Body: msg.Body, - } - - responseData, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal HMR message: %w", err) - } - - // Send HMR with HIGH priority so it doesn't get blocked by fetch requests - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 0, // HIGH PRIORITY for HMR! - }: - case <-time.After(wsWriteTimeout): - return errors.New("timeout sending HMR message") - } - - return nil -} - -func (vb *ViteBridge) handleViteHMRMessages(ctx context.Context) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-vb.stopChan: - return nil - default: - } - - _, message, err := vb.hmrConn.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Infof(vb.ctx, "[vite_bridge] Vite HMR connection closed, reconnecting...") - time.Sleep(time.Second) - if err := vb.connectToViteHMR(); err != nil { - return fmt.Errorf("failed to reconnect to Vite HMR: %w", err) - } - continue - } - return err - } - - response := ViteBridgeMessage{ - Type: "hmr:message", - Body: string(message), - } - - responseData, err := json.Marshal(response) - if err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Failed to marshal Vite HMR message: %v", err) - continue - } - - select { - case vb.tunnelWriteChan <- prioritizedMessage{ - messageType: websocket.TextMessage, - data: responseData, - priority: 0, - }: - case <-time.After(wsWriteTimeout): - log.Errorf(vb.ctx, "[vite_bridge] Timeout sending Vite HMR message") - } - } -} - -func (vb *ViteBridge) Start() error { - appDomain, err := vb.GetAppDomain() - if err != nil { - return fmt.Errorf("failed to get app domain: %w", err) - } - - if err := vb.connectToTunnel(appDomain); err != nil { - return err - } - - readyChan := make(chan error, 1) - go func() { - for vb.tunnelID == "" { - _, message, err := vb.tunnelConn.ReadMessage() - if err != nil { - readyChan <- err - return - } - - var msg ViteBridgeMessage - if err := json.Unmarshal(message, &msg); err != nil { - continue - } - - if msg.Type == "tunnel:ready" { - vb.tunnelID = msg.TunnelID - log.Infof(vb.ctx, "[vite_bridge] Tunnel ID assigned: %s", vb.tunnelID) - readyChan <- nil - return - } - } - }() - - select { - case err := <-readyChan: - if err != nil { - return fmt.Errorf("failed waiting for tunnel ready: %w", err) - } - case <-time.After(bridgeTunnelReadyTimeout): - return errors.New("timeout waiting for tunnel ready") - } - - if err := vb.connectToViteHMR(); err != nil { - return err - } - - cmdio.LogString(vb.ctx, fmt.Sprintf("\n🌐 App URL:\n%s?dev=true\n", appDomain.String())) - cmdio.LogString(vb.ctx, fmt.Sprintf("\nšŸ”— Shareable URL:\n%s?dev=%s\n", appDomain.String(), vb.tunnelID)) - - g, gCtx := errgroup.WithContext(vb.ctx) - - // Start dedicated tunnel writer goroutine - g.Go(func() error { - if err := vb.tunnelWriter(gCtx); err != nil { - return fmt.Errorf("tunnel writer error: %w", err) - } - return nil - }) - - // Connection request handler - not in errgroup to avoid blocking other handlers - go func() { - for { - select { - case msg := <-vb.connectionRequests: - if err := vb.handleConnectionRequest(msg); err != nil { - log.Errorf(vb.ctx, "[vite_bridge] Error handling connection request: %v", err) - } - case <-gCtx.Done(): - return - case <-vb.stopChan: - return - } - } - }() - - g.Go(func() error { - if err := vb.handleTunnelMessages(gCtx); err != nil { - return fmt.Errorf("tunnel message handler error: %w", err) - } - return nil - }) - - g.Go(func() error { - if err := vb.handleViteHMRMessages(gCtx); err != nil { - return fmt.Errorf("vite HMR message handler error: %w", err) - } - return nil - }) - - <-gCtx.Done() - vb.Stop() - return g.Wait() -} - -func (vb *ViteBridge) Stop() { - vb.stopOnce.Do(func() { - close(vb.stopChan) - - if vb.tunnelConn != nil { - _ = vb.tunnelConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - vb.tunnelConn.Close() - } - - if vb.hmrConn != nil { - _ = vb.hmrConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - vb.hmrConn.Close() - } - }) -} diff --git a/cmd/workspace/apps/vite_bridge_test.go b/cmd/workspace/apps/vite_bridge_test.go deleted file mode 100644 index 8d5f5c3f8d..0000000000 --- a/cmd/workspace/apps/vite_bridge_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package apps - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/databricks-sdk-go" - "github.com/gorilla/websocket" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestValidateFilePath(t *testing.T) { - // Create a temporary directory structure for testing - tmpDir := t.TempDir() - oldWd, err := os.Getwd() - require.NoError(t, err) - defer func() { _ = os.Chdir(oldWd) }() - - // Change to temp directory - err = os.Chdir(tmpDir) - require.NoError(t, err) - - // Create the allowed directory - queriesDir := filepath.Join(tmpDir, "config", "queries") - err = os.MkdirAll(queriesDir, 0o755) - require.NoError(t, err) - - // Create a valid test file - validFile := filepath.Join(queriesDir, "test.sql") - err = os.WriteFile(validFile, []byte("SELECT * FROM table"), 0o644) - require.NoError(t, err) - - tests := []struct { - name string - path string - expectError bool - errorMsg string - }{ - { - name: "valid file path", - path: "config/queries/test.sql", - expectError: false, - }, - { - name: "path outside allowed directory", - path: "../../etc/passwd", - expectError: true, - errorMsg: "outside allowed directory", - }, - { - name: "wrong file extension", - path: "config/queries/test.txt", - expectError: true, - errorMsg: "only .sql files are allowed", - }, - { - name: "hidden file", - path: "config/queries/.hidden.sql", - expectError: true, - errorMsg: "hidden files are not allowed", - }, - { - name: "path traversal attempt", - path: "config/queries/../../../etc/passwd", - expectError: true, - errorMsg: "outside allowed directory", - }, - { - name: "prefix attack - similar directory name", - path: "config/queries-malicious/test.sql", - expectError: true, - errorMsg: "outside allowed directory", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateFilePath(tt.path) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestViteBridgeMessageSerialization(t *testing.T) { - tests := []struct { - name string - msg ViteBridgeMessage - }{ - { - name: "tunnel ready message", - msg: ViteBridgeMessage{ - Type: "tunnel:ready", - TunnelID: "test-tunnel-123", - }, - }, - { - name: "fetch request message", - msg: ViteBridgeMessage{ - Type: "fetch", - Path: "/src/components/ui/card.tsx", - Method: "GET", - RequestID: "req-123", - }, - }, - { - name: "connection request message", - msg: ViteBridgeMessage{ - Type: "connection:request", - Viewer: "user@example.com", - RequestID: "req-456", - }, - }, - { - name: "fetch response with headers", - msg: ViteBridgeMessage{ - Type: "fetch:response:meta", - Status: 200, - Headers: map[string]any{ - "Content-Type": "application/json", - }, - RequestID: "req-789", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, err := json.Marshal(tt.msg) - require.NoError(t, err) - - var decoded ViteBridgeMessage - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - - assert.Equal(t, tt.msg.Type, decoded.Type) - assert.Equal(t, tt.msg.TunnelID, decoded.TunnelID) - assert.Equal(t, tt.msg.Path, decoded.Path) - assert.Equal(t, tt.msg.Method, decoded.Method) - assert.Equal(t, tt.msg.RequestID, decoded.RequestID) - }) - } -} - -func TestViteBridgeHandleMessage(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - - w := &databricks.WorkspaceClient{} - - vb := NewViteBridge(ctx, w, "test-app", 5173) - - tests := []struct { - name string - msg *ViteBridgeMessage - expectError bool - }{ - { - name: "tunnel ready message", - msg: &ViteBridgeMessage{ - Type: "tunnel:ready", - TunnelID: "tunnel-123", - }, - expectError: false, - }, - { - name: "unknown message type", - msg: &ViteBridgeMessage{ - Type: "unknown:type", - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := vb.handleMessage(tt.msg) - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - - if tt.msg.Type == "tunnel:ready" { - assert.Equal(t, tt.msg.TunnelID, vb.tunnelID) - } - }) - } -} - -func TestViteBridgeHandleFileReadRequest(t *testing.T) { - // Create a temporary directory structure - tmpDir := t.TempDir() - oldWd, err := os.Getwd() - require.NoError(t, err) - defer func() { _ = os.Chdir(oldWd) }() - - err = os.Chdir(tmpDir) - require.NoError(t, err) - - queriesDir := filepath.Join(tmpDir, "config", "queries") - err = os.MkdirAll(queriesDir, 0o755) - require.NoError(t, err) - - testContent := "SELECT * FROM users WHERE id = 1" - testFile := filepath.Join(queriesDir, "test_query.sql") - err = os.WriteFile(testFile, []byte(testContent), 0o644) - require.NoError(t, err) - - t.Run("successful file read", func(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - w := &databricks.WorkspaceClient{} - - // Create a mock tunnel connection using httptest - var lastMessage []byte - upgrader := websocket.Upgrader{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("failed to upgrade: %v", err) - return - } - defer conn.Close() - - // Read the message sent by handleFileReadRequest - _, message, err := conn.ReadMessage() - if err != nil { - t.Errorf("failed to read message: %v", err) - return - } - lastMessage = message - })) - defer server.Close() - - // Connect to the mock server - wsURL := "ws" + server.URL[4:] - conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer resp.Body.Close() - defer conn.Close() - - vb := NewViteBridge(ctx, w, "test-app", 5173) - vb.tunnelConn = conn - - go func() { _ = vb.tunnelWriter(ctx) }() - - msg := &ViteBridgeMessage{ - Type: "file:read", - Path: "config/queries/test_query.sql", - RequestID: "req-123", - } - - err = vb.handleFileReadRequest(msg) - require.NoError(t, err) - - // Give the message time to be sent - time.Sleep(100 * time.Millisecond) - - // Parse the response - var response ViteBridgeMessage - err = json.Unmarshal(lastMessage, &response) - require.NoError(t, err) - - assert.Equal(t, "file:read:response", response.Type) - assert.Equal(t, "req-123", response.RequestID) - assert.Equal(t, testContent, response.Content) - assert.Empty(t, response.Error) - }) - - t.Run("file not found", func(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - w := &databricks.WorkspaceClient{} - - var lastMessage []byte - upgrader := websocket.Upgrader{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - t.Errorf("failed to upgrade: %v", err) - return - } - defer conn.Close() - - _, message, err := conn.ReadMessage() - if err != nil { - t.Errorf("failed to read message: %v", err) - return - } - lastMessage = message - })) - defer server.Close() - - wsURL := "ws" + server.URL[4:] - conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - require.NoError(t, err) - defer resp.Body.Close() - defer conn.Close() - - vb := NewViteBridge(ctx, w, "test-app", 5173) - vb.tunnelConn = conn - - go func() { _ = vb.tunnelWriter(ctx) }() - - msg := &ViteBridgeMessage{ - Type: "file:read", - Path: "config/queries/nonexistent.sql", - RequestID: "req-456", - } - - err = vb.handleFileReadRequest(msg) - require.NoError(t, err) - - // Give the message time to be sent - time.Sleep(100 * time.Millisecond) - - var response ViteBridgeMessage - err = json.Unmarshal(lastMessage, &response) - require.NoError(t, err) - - assert.Equal(t, "file:read:response", response.Type) - assert.Equal(t, "req-456", response.RequestID) - assert.NotEmpty(t, response.Error) - }) -} - -func TestViteBridgeStop(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - w := &databricks.WorkspaceClient{} - - vb := NewViteBridge(ctx, w, "test-app", 5173) - - // Call Stop multiple times to ensure it's idempotent - vb.Stop() - vb.Stop() - vb.Stop() - - // Verify stopChan is closed - select { - case <-vb.stopChan: - // Channel is closed, this is expected - default: - t.Error("stopChan should be closed after Stop()") - } -} - -func TestNewViteBridge(t *testing.T) { - ctx := context.Background() - w := &databricks.WorkspaceClient{} - appName := "test-app" - - vb := NewViteBridge(ctx, w, appName, 5173) - - assert.NotNil(t, vb) - assert.Equal(t, appName, vb.appName) - assert.NotNil(t, vb.httpClient) - assert.NotNil(t, vb.stopChan) - assert.NotNil(t, vb.connectionRequests) - assert.Equal(t, 10, cap(vb.connectionRequests)) -} diff --git a/experimental/dev/cmd/app/app.go b/experimental/dev/cmd/app/app.go deleted file mode 100644 index f20a5ec106..0000000000 --- a/experimental/dev/cmd/app/app.go +++ /dev/null @@ -1,24 +0,0 @@ -package app - -import ( - "github.com/spf13/cobra" -) - -func New() *cobra.Command { - cmd := &cobra.Command{ - Use: "app", - Short: "Manage Databricks applications", - Long: `Manage Databricks applications. - -Provides a streamlined interface for creating, managing, and monitoring -full-stack Databricks applications built with TypeScript, React, and -Tailwind CSS.`, - } - - cmd.AddCommand(newInitCmd()) - cmd.AddCommand(newImportCmd()) - cmd.AddCommand(newDeployCmd()) - cmd.AddCommand(newDevRemoteCmd()) - - return cmd -} diff --git a/experimental/dev/cmd/app/deploy.go b/experimental/dev/cmd/app/deploy.go deleted file mode 100644 index 2ee7e394c9..0000000000 --- a/experimental/dev/cmd/app/deploy.go +++ /dev/null @@ -1,191 +0,0 @@ -package app - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/databricks/cli/bundle" - "github.com/databricks/cli/bundle/resources" - "github.com/databricks/cli/bundle/run" - "github.com/databricks/cli/cmd/bundle/utils" - "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/experimental/dev/lib/validation" - "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/cli/libs/log" - "github.com/spf13/cobra" -) - -func newDeployCmd() *cobra.Command { - var ( - force bool - skipValidation bool - ) - - cmd := &cobra.Command{ - Use: "deploy", - Short: "Validate, deploy the AppKit application and run it", - Long: `Validate, deploy the AppKit application and run it. - -This command runs a deployment pipeline: -1. Validates the project (build, typecheck, tests for Node.js projects) -2. Deploys the bundle to the workspace -3. Runs the app - -Examples: - # Deploy to default target - databricks experimental dev app deploy - - # Deploy to a specific target - databricks experimental dev app deploy --target prod - - # Skip validation (if already validated) - databricks experimental dev app deploy --skip-validation - - # Force deploy (override git branch validation) - databricks experimental dev app deploy --force - - # Set bundle variables - databricks experimental dev app deploy --var="warehouse_id=abc123"`, - Args: root.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return runDeploy(cmd, force, skipValidation) - }, - } - - cmd.Flags().StringP("target", "t", "", "Deployment target (e.g., dev, prod)") - cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation") - cmd.Flags().BoolVar(&skipValidation, "skip-validation", false, "Skip project validation (build, typecheck, lint)") - cmd.Flags().StringSlice("var", []string{}, `Set values for variables defined in bundle config. Example: --var="key=value"`) - - return cmd -} - -func runDeploy(cmd *cobra.Command, force, skipValidation bool) error { - ctx := cmd.Context() - - // Check for bundle configuration - if _, err := os.Stat("databricks.yml"); os.IsNotExist(err) { - return errors.New("no databricks.yml found; run this command from a bundle directory") - } - - // Get current working directory for validation - workDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Step 1: Validate project (unless skipped) - if !skipValidation { - validator := getProjectValidator(workDir) - if validator != nil { - result, err := validator.Validate(ctx, workDir) - if err != nil { - return fmt.Errorf("validation error: %w", err) - } - - if !result.Success { - // Show error details - if result.Details != nil { - cmdio.LogString(ctx, result.Details.Error()) - } - return errors.New("validation failed - fix errors before deploying") - } - cmdio.LogString(ctx, "āœ… "+result.Message) - } else { - log.Debugf(ctx, "No validator found for project type, skipping validation") - } - } - - // Step 2: Deploy bundle - cmdio.LogString(ctx, "Deploying bundle...") - b, err := utils.ProcessBundle(cmd, utils.ProcessOptions{ - InitFunc: func(b *bundle.Bundle) { - b.Config.Bundle.Force = force - }, - AlwaysPull: true, - FastValidate: true, - Build: true, - Deploy: true, - }) - if err != nil { - return fmt.Errorf("deploy failed: %w", err) - } - log.Infof(ctx, "Deploy completed") - - // Step 3: Detect and run app - appKey, err := detectApp(b) - if err != nil { - return err - } - - log.Infof(ctx, "Running app: %s", appKey) - if err := runApp(ctx, b, appKey); err != nil { - cmdio.LogString(ctx, "āœ” Deployment succeeded, but failed to start app") - appName := b.Config.Resources.Apps[appKey].Name - return fmt.Errorf("failed to run app: %w. Run `databricks apps logs %s` to view logs", err, appName) - } - - cmdio.LogString(ctx, "āœ” Deployment complete!") - return nil -} - -// getProjectValidator returns the appropriate validator based on project type. -// Returns nil if no validator is applicable. -func getProjectValidator(workDir string) validation.Validation { - // Check for Node.js project (package.json exists) - packageJSON := filepath.Join(workDir, "package.json") - if _, err := os.Stat(packageJSON); err == nil { - return &validation.ValidationNodeJs{} - } - return nil -} - -// detectApp finds the single app in the bundle configuration. -func detectApp(b *bundle.Bundle) (string, error) { - apps := b.Config.Resources.Apps - - if len(apps) == 0 { - return "", errors.New("no apps found in bundle configuration") - } - - if len(apps) > 1 { - return "", errors.New("multiple apps found in bundle, cannot auto-detect") - } - - for key := range apps { - return key, nil - } - - return "", errors.New("unexpected error detecting app") -} - -// runApp runs the specified app using the runner interface. -func runApp(ctx context.Context, b *bundle.Bundle, appKey string) error { - ref, err := resources.Lookup(b, appKey, run.IsRunnable) - if err != nil { - return fmt.Errorf("failed to lookup app: %w", err) - } - - runner, err := run.ToRunner(b, ref) - if err != nil { - return fmt.Errorf("failed to create runner: %w", err) - } - - output, err := runner.Run(ctx, &run.Options{}) - if err != nil { - return fmt.Errorf("failed to run app: %w", err) - } - - if output != nil { - resultString, err := output.String() - if err != nil { - return err - } - log.Infof(ctx, "App output: %s", resultString) - } - - return nil -} diff --git a/experimental/dev/cmd/app/dev_remote_test.go b/experimental/dev/cmd/app/dev_remote_test.go deleted file mode 100644 index 4c0d7eeea1..0000000000 --- a/experimental/dev/cmd/app/dev_remote_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package app - -import ( - "context" - "net" - "os" - "testing" - "time" - - "github.com/databricks/cli/experimental/dev/lib/vite" - "github.com/databricks/cli/libs/cmdio" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIsViteReady(t *testing.T) { - t.Run("vite not running", func(t *testing.T) { - // Assuming nothing is running on port 5173 - ready := isViteReady(5173) - assert.False(t, ready) - }) - - t.Run("vite is running", func(t *testing.T) { - // Start a mock server on the Vite port - listener, err := net.Listen("tcp", "localhost:5173") - require.NoError(t, err) - defer listener.Close() - - // Accept connections in the background - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - conn.Close() - } - }() - - // Give the listener a moment to start - time.Sleep(50 * time.Millisecond) - - ready := isViteReady(5173) - assert.True(t, ready) - }) -} - -func TestViteServerScriptContent(t *testing.T) { - // Verify the embedded script is not empty - assert.NotEmpty(t, vite.ServerScript) - - // Verify it's a JavaScript file with expected content - assert.Contains(t, string(vite.ServerScript), "startViteServer") -} - -func TestStartViteDevServerNoNode(t *testing.T) { - // Skip this test if node is not available or in CI environments - if os.Getenv("CI") != "" { - t.Skip("Skipping node-dependent test in CI") - } - - ctx := context.Background() - ctx = cmdio.MockDiscard(ctx) - - // Create a temporary directory to act as project root - tmpDir := t.TempDir() - oldWd, err := os.Getwd() - require.NoError(t, err) - defer func() { _ = os.Chdir(oldWd) }() - - err = os.Chdir(tmpDir) - require.NoError(t, err) - - // Create a client directory - err = os.Mkdir("client", 0o755) - require.NoError(t, err) - - // Try to start Vite server with invalid app URL (will fail fast) - // This test mainly verifies the function signature and error handling - _, _, err = startViteDevServer(ctx, "", 5173) - assert.Error(t, err) -} - -func TestViteServerScriptEmbedded(t *testing.T) { - assert.NotEmpty(t, vite.ServerScript) - - scriptContent := string(vite.ServerScript) - assert.Contains(t, scriptContent, "startViteServer") - assert.Contains(t, scriptContent, "createServer") - assert.Contains(t, scriptContent, "queriesHMRPlugin") -} diff --git a/experimental/dev/cmd/dev.go b/experimental/dev/cmd/dev.go deleted file mode 100644 index 27679e71fc..0000000000 --- a/experimental/dev/cmd/dev.go +++ /dev/null @@ -1,21 +0,0 @@ -package dev - -import ( - "github.com/databricks/cli/experimental/dev/cmd/app" - "github.com/spf13/cobra" -) - -func New() *cobra.Command { - cmd := &cobra.Command{ - Use: "dev", - Short: "Development tools for Databricks applications", - Long: `Development tools for Databricks applications. - -Provides commands for creating, developing, and deploying full-stack -Databricks applications.`, - } - - cmd.AddCommand(app.New()) - - return cmd -} diff --git a/experimental/dev/lib/features/features.go b/libs/apps/features/features.go similarity index 100% rename from experimental/dev/lib/features/features.go rename to libs/apps/features/features.go diff --git a/experimental/dev/lib/features/features_test.go b/libs/apps/features/features_test.go similarity index 100% rename from experimental/dev/lib/features/features_test.go rename to libs/apps/features/features_test.go diff --git a/experimental/dev/lib/prompt/prompt.go b/libs/apps/prompt/prompt.go similarity index 98% rename from experimental/dev/lib/prompt/prompt.go rename to libs/apps/prompt/prompt.go index d376a73fcd..885ad860b0 100644 --- a/experimental/dev/lib/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -13,7 +13,7 @@ import ( "github.com/briandowns/spinner" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/databricks/cli/experimental/dev/lib/features" + "github.com/databricks/cli/libs/apps/features" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/listing" @@ -200,7 +200,7 @@ func PromptForDeployAndRun() (deploy bool, runMode RunMode, err error) { // Deploy after creation? err = huh.NewConfirm(). Title("Deploy after creation?"). - Description("Run 'databricks experimental dev app deploy' after setup"). + Description("Run 'databricks apps deploy' after setup"). Value(&deploy). WithTheme(theme). Run() @@ -333,7 +333,7 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( // Step 5: Deploy after creation? err = huh.NewConfirm(). Title("Deploy after creation?"). - Description("Run 'databricks experimental dev app deploy' after setup"). + Description("Run 'databricks apps deploy' after setup"). Value(&config.Deploy). WithTheme(theme). Run() diff --git a/experimental/dev/lib/prompt/prompt_test.go b/libs/apps/prompt/prompt_test.go similarity index 100% rename from experimental/dev/lib/prompt/prompt_test.go rename to libs/apps/prompt/prompt_test.go diff --git a/experimental/dev/lib/validation/nodejs.go b/libs/apps/validation/nodejs.go similarity index 98% rename from experimental/dev/lib/validation/nodejs.go rename to libs/apps/validation/nodejs.go index e85dc4aae4..6ac9c929f2 100644 --- a/experimental/dev/lib/validation/nodejs.go +++ b/libs/apps/validation/nodejs.go @@ -70,7 +70,7 @@ func (v *ValidationNodeJs) Validate(ctx context.Context, workDir string) (*Valid // Check if step should be skipped if step.skipIf != nil && step.skipIf(workDir) { log.Debugf(ctx, "skipping %s (condition met)", step.name) - cmdio.LogString(ctx, fmt.Sprintf("ā­ļø Skipped %s", step.displayName)) + cmdio.LogString(ctx, "ā­ļø Skipped "+step.displayName) continue } diff --git a/experimental/dev/lib/validation/validation.go b/libs/apps/validation/validation.go similarity index 100% rename from experimental/dev/lib/validation/validation.go rename to libs/apps/validation/validation.go diff --git a/experimental/dev/lib/vite/bridge.go b/libs/apps/vite/bridge.go similarity index 100% rename from experimental/dev/lib/vite/bridge.go rename to libs/apps/vite/bridge.go diff --git a/experimental/dev/lib/vite/bridge_test.go b/libs/apps/vite/bridge_test.go similarity index 100% rename from experimental/dev/lib/vite/bridge_test.go rename to libs/apps/vite/bridge_test.go diff --git a/experimental/dev/lib/vite/server.js b/libs/apps/vite/server.js similarity index 100% rename from experimental/dev/lib/vite/server.js rename to libs/apps/vite/server.js From a641fbe910287cc6746f1213a275e4a0316a58dd Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 09:38:02 +0100 Subject: [PATCH 08/13] chore: fixup --- .../appkit/databricks_template_schema.json | 1 - .../template/{{.project_name}}/.env.tmpl | 2 +- .../config/queries/schema.ts | 28 +++++++++++++++++++ .../template/{{.project_name}}/package.json | 5 ++-- 4 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts diff --git a/experimental/aitools/templates/appkit/databricks_template_schema.json b/experimental/aitools/templates/appkit/databricks_template_schema.json index 43c6c5a356..973dab9e04 100644 --- a/experimental/aitools/templates/appkit/databricks_template_schema.json +++ b/experimental/aitools/templates/appkit/databricks_template_schema.json @@ -10,7 +10,6 @@ "sql_warehouse_id": { "type": "string", "description": "SQL Warehouse ID", - "default": "", "order": 2 }, "profile": { diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl b/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl index 8599a13ed5..be54897988 100644 --- a/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl +++ b/experimental/aitools/templates/appkit/template/{{.project_name}}/.env.tmpl @@ -1,5 +1,5 @@ {{if ne .profile ""}}DATABRICKS_CONFIG_PROFILE={{.profile}}{{else}}DATABRICKS_HOST={{workspace_host}}{{end}} DATABRICKS_WAREHOUSE_ID={{.sql_warehouse_id}} DATABRICKS_APP_PORT=8000 -DATABRICKS_APP_NAME={{.project_name}} +DATABRICKS_APP_NAME=minimal FLASK_RUN_HOST=0.0.0.0 diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts b/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts new file mode 100644 index 0000000000..1abc1821d7 --- /dev/null +++ b/experimental/aitools/templates/appkit/template/{{.project_name}}/config/queries/schema.ts @@ -0,0 +1,28 @@ +/** + * Query Result Schemas - Define the COLUMNS RETURNED by each SQL query. + * + * These schemas validate QUERY RESULTS, not input parameters. + * - Input parameters are passed to useAnalyticsQuery() as the second argument + * - These schemas define the shape of data[] returned by the query + * + * Example: + * SQL: SELECT name, age FROM users WHERE city = :city + * Schema: z.array(z.object({ name: z.string(), age: z.number() })) + * Usage: useAnalyticsQuery('users', { city: sql.string('NYC') }) + * ^ input params ^ schema validates this result + */ + +import { z } from 'zod'; +export const querySchemas = { + mocked_sales: z.array( + z.object({ + max_month_num: z.number().min(1).max(12), + }) + ), + + hello_world: z.array( + z.object({ + value: z.string(), + }) + ), +}; diff --git a/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json b/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json index a26a2dcd2b..480d310043 100644 --- a/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json +++ b/experimental/aitools/templates/appkit/template/{{.project_name}}/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc -p ./tsconfig.server.json --noEmit && tsc -p ./tsconfig.client.json --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", - "lint:ast-grep": "appkit-lint", + "lint:ast-grep": "tsx scripts/lint-ast-grep.ts", "format": "prettier --check .", "format:fix": "prettier --write .", "test": "vitest run && npm run test:smoke", @@ -20,8 +20,7 @@ "test:e2e:ui": "playwright test --ui", "test:smoke": "playwright install chromium && playwright test tests/smoke.spec.ts", "clean": "rm -rf client/dist dist build node_modules .smoke-test test-results playwright-report", - "typegen": "appkit-generate-types", - "setup": "appkit-setup --write" + "typegen": "tsx scripts/generate-types.ts" }, "keywords": [], "author": "", From 9ca1219c778480f3cb03d1948a150409e4d41aa3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 09:44:24 +0100 Subject: [PATCH 09/13] chore: fixup --- acceptance/apps/init-template/app/output.txt | 1 - acceptance/cmd/workspace/apps/output.txt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/apps/init-template/app/output.txt b/acceptance/apps/init-template/app/output.txt index 2832dbb335..db00522d0f 100644 --- a/acceptance/apps/init-template/app/output.txt +++ b/acceptance/apps/init-template/app/output.txt @@ -144,4 +144,3 @@ See the databricks experimental aitools tools validate instead of running these - [Testing](docs/testing.md) - vitest unit tests, Playwright smoke/E2E tests ================= - diff --git a/acceptance/cmd/workspace/apps/output.txt b/acceptance/cmd/workspace/apps/output.txt index e1524affa4..a1645dd38d 100644 --- a/acceptance/cmd/workspace/apps/output.txt +++ b/acceptance/cmd/workspace/apps/output.txt @@ -75,6 +75,7 @@ Global Flags: -o, --output type output type: text or json (default text) -p, --profile string ~/.databrickscfg profile -t, --target string bundle target to use (if applicable) + --var strings set values for variables defined in bundle config. Example: --var="key=value" Exit code: 1 From 8672b5cc2cbe65977c481edfa567101c99b95e05 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 11:01:39 +0100 Subject: [PATCH 10/13] chore: validate command --- cmd/apps/apps.go | 1 + cmd/apps/deploy_bundle.go | 14 +----- cmd/apps/validate.go | 72 ++++++++++++++++++++++++++++++ libs/apps/validation/validation.go | 14 ++++++ 4 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 cmd/apps/validate.go diff --git a/cmd/apps/apps.go b/cmd/apps/apps.go index eb10c8e83b..91244517a7 100644 --- a/cmd/apps/apps.go +++ b/cmd/apps/apps.go @@ -15,5 +15,6 @@ func Commands() []*cobra.Command { newDevRemoteCmd(), newLogsCommand(), newRunLocal(), + newValidateCmd(), } } diff --git a/cmd/apps/deploy_bundle.go b/cmd/apps/deploy_bundle.go index 1a6dbc32cd..a3b7b3b71e 100644 --- a/cmd/apps/deploy_bundle.go +++ b/cmd/apps/deploy_bundle.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/resources" @@ -111,7 +110,7 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation bool) error { // Step 1: Validate project (unless skipped) if !skipValidation { - validator := getProjectValidator(workDir) + validator := validation.GetProjectValidator(workDir) if validator != nil { result, err := validator.Validate(ctx, workDir) if err != nil { @@ -166,17 +165,6 @@ func runBundleDeploy(cmd *cobra.Command, force, skipValidation bool) error { return nil } -// getProjectValidator returns the appropriate validator based on project type. -// Returns nil if no validator is applicable. -func getProjectValidator(workDir string) validation.Validation { - // Check for Node.js project (package.json exists) - packageJSON := filepath.Join(workDir, "package.json") - if _, err := os.Stat(packageJSON); err == nil { - return &validation.ValidationNodeJs{} - } - return nil -} - // detectBundleApp finds the single app in the bundle configuration. func detectBundleApp(b *bundle.Bundle) (string, error) { bundleApps := b.Config.Resources.Apps diff --git a/cmd/apps/validate.go b/cmd/apps/validate.go new file mode 100644 index 0000000000..ab37913a27 --- /dev/null +++ b/cmd/apps/validate.go @@ -0,0 +1,72 @@ +package apps + +import ( + "errors" + "fmt" + "os" + + "github.com/databricks/cli/libs/apps/validation" + "github.com/databricks/cli/libs/cmdio" + "github.com/spf13/cobra" +) + +func newValidateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate a Databricks App project", + Long: `Validate a Databricks App project by running build, typecheck, and lint checks. + +This command detects the project type and runs the appropriate validation: +- Node.js projects (package.json): runs npm install, build, typecheck, and lint + +Examples: + # Validate the current directory + databricks apps validate + + # Validate a specific directory + databricks apps validate --path ./my-app`, + RunE: func(cmd *cobra.Command, args []string) error { + return runValidate(cmd) + }, + } + + cmd.Flags().String("path", "", "Path to the project directory (defaults to current directory)") + + return cmd +} + +func runValidate(cmd *cobra.Command) error { + ctx := cmd.Context() + + // Get project path + projectPath, _ := cmd.Flags().GetString("path") + if projectPath == "" { + var err error + projectPath, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + } + + // Get validator for project type + validator := validation.GetProjectValidator(projectPath) + if validator == nil { + return errors.New("no supported project type detected (looking for package.json)") + } + + // Run validation + result, err := validator.Validate(ctx, projectPath) + if err != nil { + return fmt.Errorf("validation error: %w", err) + } + + if !result.Success { + if result.Details != nil { + cmdio.LogString(ctx, result.Details.Error()) + } + return errors.New("validation failed") + } + + cmdio.LogString(ctx, "āœ… "+result.Message) + return nil +} diff --git a/libs/apps/validation/validation.go b/libs/apps/validation/validation.go index 6ff4daa636..804b725e02 100644 --- a/libs/apps/validation/validation.go +++ b/libs/apps/validation/validation.go @@ -3,6 +3,8 @@ package validation import ( "context" "fmt" + "os" + "path/filepath" ) // ValidationDetail contains detailed output from a failed validation. @@ -53,3 +55,15 @@ func (vr *ValidateResult) String() string { type Validation interface { Validate(ctx context.Context, workDir string) (*ValidateResult, error) } + +// GetProjectValidator returns the appropriate validator based on project type. +// Returns nil if no validator is applicable. +func GetProjectValidator(workDir string) Validation { + // Check for Node.js project (package.json exists) + packageJSON := filepath.Join(workDir, "package.json") + if _, err := os.Stat(packageJSON); err == nil { + return &ValidationNodeJs{} + } + // TODO: Extend this with other project types as needed (e.g. python, etc.) + return nil +} From 38a978ca853a2e860968eb9c0f8b7e101cc8d2b5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 11:08:36 +0100 Subject: [PATCH 11/13] chore: fixup --- acceptance/apps/init-template/app/output.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/acceptance/apps/init-template/app/output.txt b/acceptance/apps/init-template/app/output.txt index db00522d0f..2832dbb335 100644 --- a/acceptance/apps/init-template/app/output.txt +++ b/acceptance/apps/init-template/app/output.txt @@ -144,3 +144,4 @@ See the databricks experimental aitools tools validate instead of running these - [Testing](docs/testing.md) - vitest unit tests, Playwright smoke/E2E tests ================= + From 94fcc2bbcd674ded6e7af1f49158ee77cdf6be24 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 11:17:58 +0100 Subject: [PATCH 12/13] chore: remove import command --- cmd/apps/apps.go | 1 - cmd/apps/import.go | 200 --------------------------------------------- 2 files changed, 201 deletions(-) delete mode 100644 cmd/apps/import.go diff --git a/cmd/apps/apps.go b/cmd/apps/apps.go index 91244517a7..999161a55b 100644 --- a/cmd/apps/apps.go +++ b/cmd/apps/apps.go @@ -11,7 +11,6 @@ const ManagementGroupID = "management" func Commands() []*cobra.Command { return []*cobra.Command{ newInitCmd(), - newImportCmd(), newDevRemoteCmd(), newLogsCommand(), newRunLocal(), diff --git a/cmd/apps/import.go b/cmd/apps/import.go deleted file mode 100644 index d40f2e788e..0000000000 --- a/cmd/apps/import.go +++ /dev/null @@ -1,200 +0,0 @@ -package apps - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/databricks/cli/bundle/generate" - "github.com/databricks/cli/cmd/root" - "github.com/databricks/cli/libs/apps/prompt" - "github.com/databricks/cli/libs/cmdctx" - "github.com/databricks/cli/libs/cmdio" - "github.com/databricks/databricks-sdk-go/service/apps" - "github.com/spf13/cobra" -) - -func newImportCmd() *cobra.Command { - var ( - appName string - force bool - outputDir string - ) - - cmd := &cobra.Command{ - Use: "import", - Short: "Import app source code from Databricks workspace to local disk", - Long: `Import app source code from Databricks workspace to local disk. - -Downloads the source code of a deployed Databricks app to a local directory -named after the app. - -Examples: - # Interactive mode - select app from picker - databricks apps import - - # Import a specific app's source code - databricks apps import --name my-app - - # Import to a specific directory - databricks apps import --name my-app --output-dir ./projects - - # Force overwrite existing files - databricks apps import --name my-app --force`, - Args: root.NoArgs, - PreRunE: root.MustWorkspaceClient, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - // Prompt for app name if not provided - if appName == "" { - selected, err := prompt.PromptForAppSelection(ctx, "Select an app to import") - if err != nil { - return err - } - appName = selected - } - - return runImport(ctx, importOptions{ - appName: appName, - force: force, - outputDir: outputDir, - }) - }, - } - - cmd.Flags().StringVar(&appName, "name", "", "Name of the app to import (prompts if not provided)") - cmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to write the imported app to") - cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files") - - return cmd -} - -type importOptions struct { - appName string - force bool - outputDir string -} - -func runImport(ctx context.Context, opts importOptions) error { - w := cmdctx.WorkspaceClient(ctx) - - // Step 1: Fetch the app - var app *apps.App - err := prompt.RunWithSpinnerCtx(ctx, fmt.Sprintf("Fetching app '%s'...", opts.appName), func() error { - var fetchErr error - app, fetchErr = w.Apps.Get(ctx, apps.GetAppRequest{Name: opts.appName}) - return fetchErr - }) - if err != nil { - return fmt.Errorf("failed to get app: %w", err) - } - - // Step 2: Check if the app has a source code path - if app.DefaultSourceCodePath == "" { - return errors.New("app has no source code path - it may not have been deployed yet") - } - - cmdio.LogString(ctx, "Source code path: "+app.DefaultSourceCodePath) - - // Step 3: Create output directory - destDir := opts.appName - if opts.outputDir != "" { - destDir = filepath.Join(opts.outputDir, opts.appName) - } - if err := ensureOutputDir(destDir, opts.force); err != nil { - return err - } - - // Step 4: Download files using the Downloader - downloader := generate.NewDownloader(w, destDir, destDir) - sourceCodePath := app.DefaultSourceCodePath - - err = prompt.RunWithSpinnerCtx(ctx, "Downloading files...", func() error { - if markErr := downloader.MarkDirectoryForDownload(ctx, &sourceCodePath); markErr != nil { - return fmt.Errorf("failed to list files: %w", markErr) - } - return downloader.FlushToDisk(ctx, opts.force) - }) - if err != nil { - return fmt.Errorf("failed to download files for app '%s': %w", opts.appName, err) - } - - // Count downloaded files - fileCount := countFiles(destDir) - - // Get absolute path for display - absDestDir, err := filepath.Abs(destDir) - if err != nil { - absDestDir = destDir - } - - // Step 5: Run npm install if package.json exists - packageJSONPath := filepath.Join(destDir, "package.json") - if _, err := os.Stat(packageJSONPath); err == nil { - if err := runNpmInstallInDir(ctx, destDir); err != nil { - cmdio.LogString(ctx, fmt.Sprintf("⚠ npm install failed: %v", err)) - cmdio.LogString(ctx, " You can run 'npm install' manually in the project directory.") - } - } - - // Step 6: Detect and configure DABs - bundlePath := filepath.Join(destDir, "databricks.yml") - if _, err := os.Stat(bundlePath); err == nil { - cmdio.LogString(ctx, "") - cmdio.LogString(ctx, "Detected Databricks Asset Bundle configuration.") - cmdio.LogString(ctx, "Run 'databricks bundle validate' to verify the bundle is configured correctly.") - } - - // Show success message with next steps - prompt.PrintSuccess(opts.appName, absDestDir, fileCount, true) - - return nil -} - -// runNpmInstallInDir runs npm install in the specified directory. -func runNpmInstallInDir(ctx context.Context, dir string) error { - if _, err := exec.LookPath("npm"); err != nil { - return errors.New("npm not found: please install Node.js") - } - - return prompt.RunWithSpinnerCtx(ctx, "Installing dependencies...", func() error { - cmd := exec.CommandContext(ctx, "npm", "install") - cmd.Dir = dir - cmd.Stdout = nil - cmd.Stderr = nil - return cmd.Run() - }) -} - -// ensureOutputDir creates the output directory or checks if it's safe to use. -func ensureOutputDir(dir string, force bool) error { - info, err := os.Stat(dir) - if err == nil { - if !info.IsDir() { - return fmt.Errorf("%s exists but is not a directory", dir) - } - if !force { - return fmt.Errorf("directory %s already exists (use --force to overwrite)", dir) - } - } else if !os.IsNotExist(err) { - return err - } - - return os.MkdirAll(dir, 0o755) -} - -// countFiles counts the number of files (non-directories) in a directory tree. -func countFiles(dir string) int { - count := 0 - _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err == nil && !info.IsDir() { - count++ - } - return nil - }) - return count -} From 7eb0a86699efb451ae665dbacddb97302320e55b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 16 Jan 2026 18:14:39 +0100 Subject: [PATCH 13/13] chore: fixup --- cmd/apps/init.go | 9 +++++- libs/apps/prompt/prompt.go | 66 +++++++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/cmd/apps/init.go b/cmd/apps/init.go index ea6c1957b5..c0a73e595e 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -182,11 +182,17 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) Description("space to toggle, enter to confirm"). Options(options...). Value(&config.Features). + Height(8). WithTheme(theme). Run() if err != nil { return nil, err } + if len(config.Features) == 0 { + prompt.PrintAnswered("Features", "None") + } else { + prompt.PrintAnswered("Features", fmt.Sprintf("%d selected", len(config.Features))) + } } // Step 2: Prompt for feature dependencies @@ -226,6 +232,7 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) if err := input.WithTheme(theme).Run(); err != nil { return nil, err } + prompt.PrintAnswered(dep.Title, value) config.Dependencies[dep.ID] = value } @@ -240,10 +247,10 @@ func promptForFeaturesAndDeps(ctx context.Context, preSelectedFeatures []string) if err != nil { return nil, err } - if config.Description == "" { config.Description = prompt.DefaultAppDescription } + prompt.PrintAnswered("Description", config.Description) // Step 4: Deploy and run options config.Deploy, config.RunMode, err = prompt.PromptForDeployAndRun() diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 885ad860b0..8ff2b19a61 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -41,6 +41,25 @@ func AppkitTheme() *huh.Theme { return t } +// Styles for printing answered prompts. +var ( + answeredTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#71717A")) + answeredValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFAB00")). + Bold(true) +) + +// PrintAnswered prints a completed prompt answer to keep history visible. +func PrintAnswered(title, value string) { + fmt.Printf("%s %s\n", answeredTitleStyle.Render(title+":"), answeredValueStyle.Render(value)) +} + +// printAnswered is an alias for internal use. +func printAnswered(title, value string) { + PrintAnswered(title, value) +} + // RunMode specifies how to run the app after creation. type RunMode string @@ -123,11 +142,9 @@ func PromptForProjectName(outputDir string) (string, error) { Placeholder("my-app"). Value(&name). Validate(func(s string) error { - // First validate the name format if err := ValidateProjectName(s); err != nil { return err } - // Then check if directory already exists destDir := s if outputDir != "" { destDir = filepath.Join(outputDir, s) @@ -143,6 +160,7 @@ func PromptForProjectName(outputDir string) (string, error) { return "", err } + printAnswered("Project name", name) return name, nil } @@ -187,6 +205,7 @@ func PromptForPluginDependencies(ctx context.Context, deps []features.FeatureDep if err := input.WithTheme(theme).Run(); err != nil { return nil, err } + printAnswered(dep.Title, value) result[dep.ID] = value } @@ -207,6 +226,11 @@ func PromptForDeployAndRun() (deploy bool, runMode RunMode, err error) { if err != nil { return false, RunModeNone, err } + if deploy { + printAnswered("Deploy after creation", "Yes") + } else { + printAnswered("Deploy after creation", "No") + } // Run the app? runModeStr := string(RunModeNone) @@ -225,11 +249,18 @@ func PromptForDeployAndRun() (deploy bool, runMode RunMode, err error) { return false, RunModeNone, err } + runModeLabels := map[string]string{ + string(RunModeNone): "No", + string(RunModeDev): "Yes (local)", + string(RunModeDevRemote): "Yes (remote)", + } + printAnswered("Run after creation", runModeLabels[runModeStr]) + return deploy, RunMode(runModeStr), nil } // PromptForProjectConfig shows an interactive form to gather project configuration. -// Flow: name -> features -> feature dependencies -> description. +// Flow: name -> features -> feature dependencies -> description -> deploy/run. // If preSelectedFeatures is provided, the feature selection prompt is skipped. func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) (*CreateProjectConfig, error) { config := &CreateProjectConfig{ @@ -252,6 +283,7 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( if err != nil { return nil, err } + printAnswered("Project name", config.ProjectName) // Step 2: Feature selection (skip if features already provided via flag) if len(config.Features) == 0 && len(features.AvailableFeatures) > 0 { @@ -266,11 +298,17 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( Description("space to toggle, enter to confirm"). Options(options...). Value(&config.Features). + Height(8). WithTheme(theme). Run() if err != nil { return nil, err } + if len(config.Features) == 0 { + printAnswered("Features", "None") + } else { + printAnswered("Features", fmt.Sprintf("%d selected", len(config.Features))) + } } // Step 3: Prompt for feature dependencies @@ -310,12 +348,12 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( if err := input.WithTheme(theme).Run(); err != nil { return nil, err } + printAnswered(dep.Title, value) config.Dependencies[dep.ID] = value } // Step 4: Description config.Description = DefaultAppDescription - err = huh.NewInput(). Title("Description"). Placeholder(DefaultAppDescription). @@ -325,10 +363,10 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( if err != nil { return nil, err } - if config.Description == "" { config.Description = DefaultAppDescription } + printAnswered("Description", config.Description) // Step 5: Deploy after creation? err = huh.NewConfirm(). @@ -340,6 +378,11 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( if err != nil { return nil, err } + if config.Deploy { + printAnswered("Deploy after creation", "Yes") + } else { + printAnswered("Deploy after creation", "No") + } // Step 6: Run the app? runModeStr := string(RunModeNone) @@ -359,6 +402,13 @@ func PromptForProjectConfig(ctx context.Context, preSelectedFeatures []string) ( } config.RunMode = RunMode(runModeStr) + runModeLabels := map[string]string{ + string(RunModeNone): "No", + string(RunModeDev): "Yes (local)", + string(RunModeDevRemote): "Yes (remote)", + } + printAnswered("Run after creation", runModeLabels[runModeStr]) + return config, nil } @@ -393,10 +443,12 @@ func PromptForWarehouse(ctx context.Context) (string, error) { // Build options with warehouse name and state options := make([]huh.Option[string], 0, len(warehouses)) + warehouseNames := make(map[string]string) // id -> name for printing for _, wh := range warehouses { state := string(wh.State) label := fmt.Sprintf("%s (%s)", wh.Name, state) options = append(options, huh.NewOption(label, wh.Id)) + warehouseNames[wh.Id] = wh.Name } var selected string @@ -406,12 +458,14 @@ func PromptForWarehouse(ctx context.Context) (string, error) { Options(options...). Value(&selected). Filtering(true). + Height(8). WithTheme(theme). Run() if err != nil { return "", err } + printAnswered("SQL Warehouse", warehouseNames[selected]) return selected, nil } @@ -506,12 +560,14 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { Options(options...). Value(&selected). Filtering(true). + Height(8). WithTheme(theme). Run() if err != nil { return "", err } + printAnswered("App", selected) return selected, nil }