Skip to main content

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 need m start to 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 /frontend directory 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:

OptionDistributionProsCons
Bundled Static + ScaffoldPyPI onlySingle registry, simple DX~10MB package size
npm PackagePyPI + npmSmaller Python packageTwo registries, complex CI/CD
Docker OnlyDocker HubClean separationPoor development DX
No BundlingGit clone requiredNo bundle sizeTerrible DX

Decision

We will bundle pre-built Desk static files in the framework-m PyPI package, provide optional frontend scaffolding for customization, and publish a @framework-m/desk npm 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:

ComponentLocationPurposeRegistry
Bundled Deskframework_m/static/Pre-built React app in PyPI packagePyPI
Frontend Templateframework_m/templates/frontend/Scaffold source for customizationPyPI (bundled)
Shared Components@framework-m/deskReusable Refine providers/componentsGitLab 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 start works 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/desk prevents 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 .npmrc setup instructions, can fallback to public npm later
  • 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
  • Template Updates: Scaffolded projects pull @framework-m/desk updates via pnpm 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

CommandDescription
m startStart backend + serve bundled Desk (static files)
m start --port 8000Custom port
m start --reloadDev mode with auto-reload
m start --with-frontendStart backend + Vite dev server (auto-scaffolds)
m init:frontendScaffold frontend only (no start)

Implementation Checklist

  • Create build:frontend CI job in .gitlab/ci/build.yml
  • Include static/ and templates/ in pyproject.toml package data
  • Implement static file serving in libs/framework-m-standard/src/adapters/web/app.py
  • Create m start command in libs/framework-m/src/framework_m/cli/serve.py
  • Create m init:frontend command in libs/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

FileActionDescription
.gitlab/ci/build.ymlMODIFYAdd build:frontend job
libs/framework-m/pyproject.tomlMODIFYInclude static/ and templates/ in package data
libs/framework-m/src/framework_m/static/NEWPre-built Desk (from CI)
libs/framework-m/src/framework_m/templates/frontend/NEWFrontend scaffold template
libs/framework-m/src/framework_m/cli/serve.pyNEWm start command
libs/framework-m/src/framework_m/cli/init.pyMODIFYAdd m init:frontend command
libs/framework-m-standard/src/adapters/web/app.pyMODIFYServe static files at /

Questions for Review

  1. Package Size: Is ~10MB PyPI package acceptable for bundled Desk? (PyPI limit is 100MB)
  2. Template Updates: How should users get template updates after scaffolding? (Manual merge vs. re-scaffold)
  3. CDN Strategy: Should template use CDN for large deps in dev mode to reduce scaffold size?

References