ADR-0007: Bundled Desk Distribution via PyPI
- Status: Accepted
- Date: 2026-01-29
- Deciders: @anshpansuriya14
- Supersedes: N/A
- Superseded by: N/A
Context
Framework M requires a distribution strategy for the Desk frontend UI that provides an excellent developer experience without adding package management complexity.
Forces at play:
- Developer Experience: Third-party developers should not need to clone the monorepo to use Framework M
- Single Command Start: Similar to Frappe's
bench start, we needm startto work immediately - Package Management: Multiple package registries (PyPI + npm) create deployment complexity
- Frontend Customization: Advanced users need ability to customize the Desk UI
- Maintenance Burden: Each package registry requires CI/CD, versioning, and support
- Bundle Size: Pre-built frontends increase PyPI package size
Current State:
- Frontend exists only in
/frontenddirectory of monorepo - Developers must clone entire repository to access Desk UI
- No integrated start command for backend + frontend
Desired Developer Experience:
pip install framework-m
m new:project crm
cd crm
m start # Works immediately with bundled Desk
m start --with-frontend # For frontend customization (scaffolds React app)
Alternatives Evaluated:
| Option | Distribution | Pros | Cons |
|---|---|---|---|
| Bundled Static + Scaffold | PyPI only | Single registry, simple DX | ~10MB package size |
| npm Package | PyPI + npm | Smaller Python package | Two registries, complex CI/CD |
| Docker Only | Docker Hub | Clean separation | Poor development DX |
| No Bundling | Git clone required | No bundle size | Terrible DX |
Decision
We will bundle pre-built Desk static files in the
framework-mPyPI package, provide optional frontend scaffolding for customization, and publish a@framework-m/desknpm package to GitLab registry for reusable Refine providers and components.
Architecture:
framework-m (PyPI Package)
│
├── framework_m/
│ ├── static/ # Pre-built Desk (from CI)
│ │ ├── index.html
│ │ ├── assets/
│ │ └── ...
│ │
│ ├── templates/
│ │ └── frontend/ # Scaffold source
│ │ ├── package.json # Uses @framework-m/desk from GitLab npm
│ │ ├── .npmrc # GitLab registry config
│ │ ├── src/
│ │ │ ├── App.tsx
│ │ │ └── components/
│ │ └── vite.config.ts
│ │
│ └── cli/
│ ├── serve.py # m start command
│ └── init.py # m init:frontend command
@framework-m/desk (GitLab npm Package)
│
├── src/
│ ├── providers/
│ │ ├── data.ts # frameworkMDataProvider
│ │ ├── auth.ts # authProvider
│ │ ├── live.ts # liveProvider
│ │ ├── constants.ts
│ │ └── types.ts
│ ├── components/
│ │ ├── DocTypeList.tsx
│ │ ├── DocTypeForm.tsx
│ │ └── DocTypeShow.tsx
│ └── hooks/
│ ├── useDocType.ts
│ └── useMetadata.ts
└── dist/ # Built library
Distribution Strategy:
| Component | Location | Purpose | Registry |
|---|---|---|---|
| Bundled Desk | framework_m/static/ | Pre-built React app in PyPI package | PyPI |
| Frontend Template | framework_m/templates/frontend/ | Scaffold source for customization | PyPI (bundled) |
| Shared Components | @framework-m/desk | Reusable Refine providers/components | GitLab npm |
Why GitLab npm Registry:
- ✅ Private by Default: Uses CI_JOB_TOKEN, no public exposure until ready
- ✅ Simple CI/CD: Already in GitLab, uses existing auth
- ✅ Optional Public: Can point to npmjs.com later via CLI config
- ✅ Monorepo Friendly: Same repo hosts Python and npm packages
- ✅ No Extra Infra: GitLab provides npm registry for free
Template npm Configuration:
# .npmrc in scaffolded frontend/
@framework-m:registry=https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/
# For local dev, users can optionally configure GitLab token or use public npm later
Consequences
Positive
- Excellent DX:
pip install framework-m && m startworks immediately - Best of Both Worlds: Bundled static for quick start + npm package for customization
- GitLab Integration: Private npm registry, uses CI_JOB_TOKEN, no extra auth setup
- Atomic Updates: Bug fixes in Desk UI ship with backend updates
- Reusable Components:
@framework-m/deskprevents code duplication in customized projects - Monorepo Clean: All packages (Python + npm) in same repo, shared CI/CD
- Flexible Publishing: Can switch to public npmjs.com later via CLI config
Negative
- Larger PyPI Package: ~10MB vs ~2MB (bundled static files)
- Mitigation: Acceptable for modern bandwidth, one-time download cost
- npm Registry Dependency: Users need GitLab token for scaffolded frontend development
- Mitigation: Template includes
.npmrcsetup instructions, can fallback to public npm later
- Mitigation: Template includes
- Two Package Systems: PyPI for backend, GitLab npm for frontend components
- Mitigation: Simpler than vendoring, better for ecosystem growth
Neutral
- Build Time: CI must build React app + npm package during release
- Added to
.gitlab/ci/build.yml, runs in parallel with other jobs
- Added to
- Template Updates: Scaffolded projects pull
@framework-m/deskupdates viapnpm update- Better than vendored approach, users get component updates easily
Implementation Plan
Phase 1: Bundled Desk (Required for v1.0)
CI Build Pipeline (.gitlab-ci.yml):
build:frontend:
stage: build
script:
- cd frontend
- pnpm install --frozen-lockfile
- pnpm build
- mkdir -p ../libs/framework-m/src/framework_m/static
- cp -r dist/* ../libs/framework-m/src/framework_m/static/
artifacts:
paths:
- libs/framework-m/src/framework_m/static/
expire_in: 1 day
only:
- tags # Only build for releases
Package Data (libs/framework-m/pyproject.toml):
[tool.setuptools.package-data]
framework_m = [
"static/**/*",
"templates/**/*",
]
Static File Serving (libs/framework-m-standard/src/adapters/web/app.py):
from importlib.resources import files
from litestar.static_files import create_static_files_router
STATIC_DIR = files("framework_m") / "static"
app.register(
create_static_files_router(
path="/",
directories=[STATIC_DIR],
html_mode=True, # Serve index.html for SPA routing
)
)
CLI Command (libs/framework-m/src/framework_m/cli/serve.py):
def start_command(
port: int = 8000,
reload: bool = False,
with_frontend: bool = False,
):
"""Start Framework M application.
Examples:
m start # Backend + bundled Desk
m start --with-frontend # Backend + Vite dev server
m start --reload # Auto-reload on code changes
"""
if with_frontend:
_start_with_frontend(port, reload)
else:
_start_backend_only(port, reload)
def _start_backend_only(port: int, reload: bool):
"""Start backend serving bundled static Desk."""
uvicorn.run(
"framework_m.app:app",
host="0.0.0.0",
port=port,
reload=reload,
)
Phase 2: Frontend Scaffold (Required for Customizers)
Auto-Scaffold Command (libs/framework-m/src/framework_m/cli/init.py):
def init_frontend_command(target: Path | None = None):
"""Scaffold frontend for customization.
Examples:
m init:frontend # Creates ./frontend
m init:frontend my-ui # Creates ./my-ui
"""
from importlib.resources import files, as_file
target = target or Path.cwd() / "frontend"
if target.exists():
raise ValueError(f"Directory {target} already exists")
# Copy template from package
template = files("framework_m") / "templates/frontend"
with as_file(template) as template_dir:
shutil.copytree(template_dir, target)
print(f"✓ Scaffolded frontend to {target}")
print(" Installing dependencies...")
subprocess.run(["pnpm", "install"], cwd=target, check=True)
print("✓ Ready! Run: m start --with-frontend")
Start with Frontend Dev Server:
def _start_with_frontend(port: int, reload: bool):
"""Start backend + frontend dev server."""
frontend_dir = Path.cwd() / "frontend"
# Auto-scaffold if not exists
if not frontend_dir.exists():
print("No frontend/ found. Scaffolding...")
init_frontend_command(frontend_dir)
# Start both processes
backend = subprocess.Popen([
"uvicorn",
"framework_m.app:app",
f"--port={port}",
"--reload" if reload else "",
])
frontend = subprocess.Popen(
["pnpm", "dev"],
cwd=frontend_dir,
)
try:
backend.wait()
frontend.wait()
except KeyboardInterrupt:
backend.terminate()
frontend.terminate()
Phase 3: Template Design (Vendor Shared Code)
Template Structure:
templates/frontend/
├── package.json # Minimal deps, no @framework-m scope
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── App.tsx
│ ├── providers/
│ │ ├── data.ts # Full implementation (vendored)
│ │ ├── auth.ts
│ │ └── live.ts
│ ├── components/
│ │ ├── DocTypeList.tsx
│ │ ├── DocTypeForm.tsx
│ │ └── DocTypeShow.tsx
│ └── hooks/
│ ├── useDocType.ts
│ └── useMetadata.ts
└── public/
No npm Package, Direct Vendoring:
// src/providers/data.ts (copied into every scaffolded project)
import type { DataProvider } from "@refinedev/core";
export const frameworkMDataProvider = (apiUrl: string): DataProvider => ({
getList: async ({ resource, pagination, filters, sorters, meta }) => {
const url = new URL(`${apiUrl}/${resource}`);
// Pagination
if (pagination) {
url.searchParams.set("page", String(pagination.current));
url.searchParams.set("pageSize", String(pagination.pageSize));
}
// Filters
if (filters) {
url.searchParams.set("filters", JSON.stringify(filters));
}
const response = await fetch(url.toString());
const data = await response.json();
return {
data: data.items,
total: data.total,
};
},
getOne: async ({ resource, id }) => {
const response = await fetch(`${apiUrl}/${resource}/${id}`);
return { data: await response.json() };
},
create: async ({ resource, variables }) => {
const response = await fetch(`${apiUrl}/${resource}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
});
return { data: await response.json() };
},
update: async ({ resource, id, variables }) => {
const response = await fetch(`${apiUrl}/${resource}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
});
return { data: await response.json() };
},
deleteOne: async ({ resource, id }) => {
await fetch(`${apiUrl}/${resource}/${id}`, { method: "DELETE" });
return { data: {} };
},
// Full implementation lives in template, no npm package needed
});
Package.json (No Custom Packages):
{
"name": "framework-m-frontend",
"version": "0.1.0",
"dependencies": {
"@refinedev/core": "^5.0.0",
"@refinedev/react-router-v6": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
Command Reference
| Command | Description |
|---|---|
m start | Start backend + serve bundled Desk (static files) |
m start --port 8000 | Custom port |
m start --reload | Dev mode with auto-reload |
m start --with-frontend | Start backend + Vite dev server (auto-scaffolds) |
m init:frontend | Scaffold frontend only (no start) |
Implementation Checklist
- Create
build:frontendCI job in.gitlab/ci/build.yml - Include
static/andtemplates/inpyproject.tomlpackage data - Implement static file serving in
libs/framework-m-standard/src/adapters/web/app.py - Create
m startcommand inlibs/framework-m/src/framework_m/cli/serve.py - Create
m init:frontendcommand inlibs/framework-m/src/framework_m/cli/init.py - Design frontend template with vendored providers in
templates/frontend/ - Update documentation for frontend customization workflow
- Test package size (ensure < 15MB total)
Alternatives Considered
Option A: npm Package Distribution
Rejected due to:
- Complexity: Requires npm registry setup, separate CI/CD pipeline
- Version Drift: Frontend and backend versions can get out of sync
- Extra Registry: Developers need both PyPI and npm configured
- CI Maintenance: Must publish to both registries on each release
Option B: Docker-Only Distribution
Rejected due to:
- Poor Development DX: Requires Docker for local development
- Slow Iteration: Rebuilding containers on each change
- Complex Debugging: Harder to debug inside containers
Option C: No Bundling (Git Clone Required)
Rejected due to:
- Terrible DX: Forces third-party developers to clone monorepo
- Confusing Structure: Monorepo has many unrelated directories
- High Barrier: Discourages adoption for simple use cases
Migration Path
For existing users (current monorepo developers):
No change required. Monorepo development continues as-is.
For new third-party developers:
# After this ADR implementation
pip install framework-m
m new:project myapp
cd myapp
m start # Just works with bundled Desk
# If customizing frontend
m start --with-frontend # Auto-scaffolds and starts Vite
Files to Create/Modify
| File | Action | Description |
|---|---|---|
.gitlab/ci/build.yml | MODIFY | Add build:frontend job |
libs/framework-m/pyproject.toml | MODIFY | Include static/ and templates/ in package data |
libs/framework-m/src/framework_m/static/ | NEW | Pre-built Desk (from CI) |
libs/framework-m/src/framework_m/templates/frontend/ | NEW | Frontend scaffold template |
libs/framework-m/src/framework_m/cli/serve.py | NEW | m start command |
libs/framework-m/src/framework_m/cli/init.py | MODIFY | Add m init:frontend command |
libs/framework-m-standard/src/adapters/web/app.py | MODIFY | Serve static files at / |
Questions for Review
- Package Size: Is ~10MB PyPI package acceptable for bundled Desk? (PyPI limit is 100MB)
- Template Updates: How should users get template updates after scaffolding? (Manual merge vs. re-scaffold)
- CDN Strategy: Should template use CDN for large deps in dev mode to reduce scaffold size?
References
- RFC-0001: Frontend Initialization and Development Experience (../rfcs/rfc-0001-frontend-init.md)
- ADR-0005: Use Refine for Frontend (./0005-refine-for-frontend.md)
- GitLab CI Publish Pipeline:
.gitlab/ci/publish.yml - Frappe Bench Architecture
- Python Packaging: Including Data Files