Metadata-Version: 2.5
Name: enlace
Version: 0.1.42
Summary: Serve all your web apps from a single process — they don't import it, they don't know it's there
Project-URL: Homepage, https://github.com/i2mint/enlace
Author: Thor Whalen
License: Apache-2.0
License-File: LICENSE
Keywords: asgi,composition,fastapi,multi-app,platform,web
Requires-Python: >=3.10
Requires-Dist: cw<0.2,>=0.1.1
Requires-Dist: fastapi>=0.100.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: uvicorn[standard]>=0.23.0
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: pillow; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Provides-Extra: icons
Requires-Dist: pillow; extra == 'icons'
Provides-Extra: process
Requires-Dist: httpx>=0.24.0; extra == 'process'
Description-Content-Type: text/markdown

# enlace

Serve all your web apps from a single command.
They don't import it. They don't know it's there.

Tired of launching a separate server for each app? Register your
apps with `enlace` — point it at where they live — and serve them
all with one command. Python ASGI apps get mounted in-process.
Non-Python apps (Node.js, Go, etc.) get spawned as supervised
child processes and routed via reverse proxy. External services
and static sites work too. Your apps stay independent — no code
changes, no shared dependencies. 
For more details, see the [Philosophy](#philosophy) section. 

## Quick start

The quickest way to start is to use enlace via AI. 
That's what we'll demo here. 
For those who want to work in the CLI or Python directly, see the later [Under the hood](#under-the-hood) section. 

### Install

```bash
pip install enlace
```

Skills are bundled with the package. To make them available to Claude Code:

```bash
# Link enlace's skills into your project (or globally)
skill link-skills "$(python -c 'import enlace; print(enlace.__path__[0])')"

# Or symlink manually
ln -s "$(python -c 'from enlace import skills_dir; print(skills_dir())')"/* .claude/skills/
```

### Using `enlace` with an AI agent

`enlace` ships with AI agent skills that let Claude Code (or any compatible
agent) handle the entire workflow through natural language:

```
"Add my_app to the platform"
"Can my_app be enlaced?"
"Diagnose /path/to/my_app and fix what you can"
"List my apps"
"List the apps configurations"
"Serve all my apps"
```


### Available skills

| Skill | What it does | Trigger phrases |
|-------|-------------|-----------------|
| **enlace** | Create apps, configure platform.toml, understand conventions, serve | "add an app", "serve my apps", "configure enlace" |
| **enlace-diagnose** | Analyze an app for compatibility, suggest fixes that preserve standalone operation | "can this be enlaced?", "diagnose this app", "what needs to change?" |
| **enlace-dev** | Modify the enlace package itself — add features, fix bugs, extend middleware | "add X to enlace", "implement Y in enlace" |

### What the AI does for you

**Onboarding an existing app:**
The agent runs `enlace diagnose`, reads the report, and presents findings in
three tiers: `enlace`-side fixes (no app changes), app changes that preserve
standalone, and warnings. It proposes specific code changes and applies them
with your approval.

**Creating a new app:**
The agent scaffolds the directory structure, writes `server.py` with a FastAPI
app, optionally creates `frontend/index.html`, registers it in `platform.toml`,
runs `enlace check`, and starts serving.

**Day-to-day operations:**
The agent runs `enlace serve`, `enlace check`, `enlace show-config` as needed,
interprets the output, and explains what's happening.



## Philosophy

**Apps don't depend on `enlace`.** `enlace` is an operator's tool, not a library
your app imports. Your app is a standard Python module with `app = FastAPI()`.
It runs standalone with `uvicorn server:app`. `enlace` just happens to know how
to find it, mount it at a route prefix, and serve it alongside other apps.
(See [Zero Coupling](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md#1-zero-coupling-apps-dont-import-enlace)
for how `enlace` provides services like auth and storage without creating a
dependency.)

```
enlace (the platform)          your app (Python, Node, Go, ...)
├── fastapi                    ├── fastapi (or express, gin, ...)
├── uvicorn                    ├── pandas (or whatever you need)
├── pydantic                   └── ... your domain libs
├── cw
└── httpx (optional, for proxy)
                               ← no arrow here: your app does NOT import enlace
```

**Two principles in tension:**

1. **Apps should not need to change.** All aggregation logic lives in `enlace`.
   When an app is hard to mount, prefer `enlace`-side config (app.toml, env vars)
   over app code changes.

2. **Enlaced apps must still work alone.** When changes are suggested, they
   preserve standalone operation. The pattern: env-var with current value as
   default — standalone uses the original, `enlace` overrides at build time.
   (See [Standalone Preservation](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md#2-standalone-preservation-enlaced-apps-must-still-work-alone)
   for the env-var-with-default pattern and fix classification.)

For the full rationale — including how these principles interact, where the
balance sits today, and what `enlace` aspires to handle better — see the
[Design Principles](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md) document.




## Under the hood

For those who want direct control, here's the CLI, Python API, and
configuration system that the skills use internally.

### Simple FastAPI example

```bash
pip install enlace
```

```bash
# Create an app
mkdir -p apps/hello
cat > apps/hello/server.py << 'EOF'
from fastapi import FastAPI
app = FastAPI()

@app.get("/greet")
def greet(name: str = "world"):
    return {"message": f"Hello, {name}!"}
EOF

# Serve it
enlace serve
# → http://localhost:8000/api/hello/greet?name=Thor
```

### CLI

```bash
enlace serve              # Start backend (dev mode, hot reload)
enlace show-config        # Resolved config with provenance
enlace check              # Validate config, check route conflicts
enlace list-apps          # Table: name, route, type, access
enlace build [app_name]   # Run declarative frontend builds (apps with a
                          # [build] section); explicit pre-deploy step,
                          # never builds at request time. --dry-run to preview.
enlace diagnose <dir>     # Analyze an app for enlace compatibility
enlace doctor --base-url http://127.0.0.1:8000
                          # Post-deploy smoke: probe /auth/csrf and every
                          # mounted app; exit nonzero on any failure.
enlace analytics [--app-name kids] [--days 30] [--json]
                          # Page views per day and per path, for apps that
                          # opted in to privacy-first analytics.
```

### Python API

```python
from enlace import diagnose_app, discover_apps, build_backend

# Diagnose an app
report = diagnose_app("/path/to/my_app")
print(report)  # Human-readable report
print(report.is_enlaceable)  # True if no critical blockers

# Discover and compose
config = discover_apps()
app = build_backend(config)  # FastAPI app with all sub-apps mounted
```

### App discovery

enlace discovers apps by filesystem conventions:

```
apps/
├── my_tool/
│   └── server.py          # has `app = FastAPI()` → mounted at /api/my_tool
├── dashboard/
│   ├── server.py           # backend
│   └── frontend/
│       └── index.html      # served at /dashboard/
├── calculator/
│   └── server.py           # typed functions, no `app` → auto-wrapped as routes
├── blog_node/
│   ├── app.toml            # mode = "process", command = ["node", "server.js"]
│   └── server.js           # spawned + proxied at /api/blog_node
└── docs/
    ├── app.toml            # mode = "static", public_dir = "dist"
    └── dist/index.html     # served directly
```

| Convention | Default | Override in `app.toml` |
|-----------|---------|----------------------|
| Serving mode | `asgi` | `mode` (`asgi`, `process`, `external`, `static`) |
| Route prefix | `/api/{dir_name}` | `route` |
| Entry point | First of `server.py`, `app.py`, `main.py` | `entry_point` |
| ASGI app object | Attribute named `app` | `app_attr` |
| Frontend assets | `frontend/index.html` | `frontend_dir` |

Everything enlace infers is inspectable (`enlace show-config --verbose`) and
overridable via `app.toml`, `platform.toml`, environment variables, or CLI flags.

#### When an app won't import

Discovering an `asgi`-mode app *imports* its entry module, so a missing
dependency — or any exception raised at module scope — is a discovery failure.
What happens next is a policy you choose:

```python
discover_apps(config)  # raise (default)
discover_apps(config, on_import_error="record")  # record and carry on
```

`serve` keeps the default: a gateway must not boot pretending a broken app is
fine. The diagnostic verbs (`doctor`, `check`, `list-apps`, `show-config`,
`app-meta`) record instead, so one broken app no longer hides the health of
every other one — `enlace doctor --json` still exits nonzero, but now with a
parseable `FAIL` naming the app and its exception class, on
`AppConfig.import_error`.

### Configuration

**`platform.toml`** (project root):

```toml
[platform]
apps_dirs = ["apps"]                # Directories containing app subdirs
app_dirs = ["/path/to/standalone"]  # Individual app directories
backend_port = 8000
cors_origins = ["*"]                # default: any origin, never with credentials;
                                    # or list origins ("https://host[:port]") that may
                                    # send credentials (then only those get CORS);
                                    # [] = no CORS. "*" can't be mixed; "null" refused.

[conventions]
entry_points = ["server.py", "app.py", "main.py"]
app_attr = "app"
frontend_dir = "frontend"
```

**`app.toml`** (per-app, in app directory):

```toml
# Python ASGI (default mode — just overrides)
route = "/api/custom-route"
entry_point = "backend/main.py"
access = "public"
display_name = "My App"
```

```toml
# Non-Python or separate process
mode = "process"
command = ["node", "server.js"]
port = 3001
route = "/api/blog"
```

```toml
# External upstream
mode = "external"
upstream_url = "http://192.168.1.50:3000"
```

```toml
# Declarative frontend build (any app with a compiled frontend)
[build]
install = "npm ci"                  # string or argv list
build = "npm run build"
env_vars = ["VITE_API_BASE"]        # vars the build honours; deployers inject values
outputs = ["dist"]                  # optional hint of produced paths
```

`enlace build` runs these as an explicit pre-deploy step (`enlace build my_app`,
or all apps with a `[build]` section). enlace never builds at request time and
does not assume a toolchain — it just runs the argv the app declares, in the
app directory, with the caller's environment plus any injected overrides
(e.g. `VITE_API_BASE`). The app keeps a normal build whether or not enlace
serves it, so the "apps don't know about enlace" principle holds.

For process/external modes: `pip install enlace[process]`

**Override precedence** (lowest → highest):

```
defaults → filesystem conventions → app.toml → platform.toml → env vars → CLI flags
```

### App modes

| Mode | Description | How it works |
|------|-------------|-------------|
| `asgi` (default) | Python ASGI apps | Imported + mounted on gateway FastAPI |
| `process` | Any app as a child process | Spawned, health-checked, reverse-proxied |
| `external` | Pre-existing upstream | Proxied, no lifecycle management |
| `static` | Static file directory | Served directly |

Within `asgi` mode, apps are further classified:

| Type | How detected | How mounted |
|------|-------------|-------------|
| `asgi_app` | Module has callable `app` attribute | `parent.mount(prefix, sub_app)` |
| `functions` | No `app` attr, has typed public functions | Auto-wrapped as API routes |
| `frontend_only` | No backend entry, has `frontend/index.html` | Static file serving only |

### Auth, sessions & plugins

enlace itself is auth-agnostic. Cross-cutting concerns like authentication,
sessions, and per-user storage are added by **plugins** — callables
`(parent: FastAPI, config: PlatformConfig) -> None` invoked once after sub-apps
are mounted:

```python
from enlace import build_backend, discover_apps

app = build_backend(discover_apps(), plugins=[my_plugin])
```

Plugins can also be loaded by name from the `ENLACE_PLUGINS` env var
(comma-separated). The canonical example is `enlace_auth.plugin`, which adds
auth, sessions, the admin dashboard, and per-user stores. Plugins reach apps
without coupling: services are injected into the ASGI scope, so apps read
`request.state.store` rather than importing anything from enlace. Apps can also
condition on the `ENLACE_MANAGED=1` env var (set by `build_backend()`), but
never have to.

### HTML caching

Every static mount (app frontends, the landing app, `mode="static"` apps, shared assets) sends `Cache-Control: no-cache` on HTML documents — including SPA fallbacks, `304` revalidations and an `html=True` `404.html` page — so browsers revalidate a page (a cheap `304` via its `ETag`) instead of heuristically reusing an old build whose HTML still names the previous asset URLs. Non-HTML assets are untouched, and a `Cache-Control` already set on a response is never overridden. The class is `enlace.frontend.RevalidatingStaticFiles` (`html_cache_control=None` opts out).

### App icons (tab, home screen, launcher)

Every app gets one icon, served in every form a browser or phone asks for:
`/_apps/{name}/icon` (as-is), `/_apps/{name}/icon-{32,180,192,512}.png`
(square PNGs, needs `pip install enlace[icons]`), and a generated
`/_apps/{name}/manifest.webmanifest`. enlace adds the matching `<link rel="icon">`,
`<link rel="apple-touch-icon">` and `<link rel="manifest">` to each app's HTML
wherever the app's own `<head>` lacks them, so the launcher tile, the browser tab
and "Add to Home Screen" (iOS and Android) all show the same image.

The icon is picked once per app: live launcher edit > `[app_meta.apps.<name>].icon`
> `<name>.png` in `[app_meta].icons_dir` > the app's own icon (from its
`<head>`, manifest or `icon.png`) > a letter monogram. An icon from the platform
tiers replaces the page's own icon links; an app's own icon only fills gaps.

```toml
[app_meta]
icons_dir = "static/app_icons"   # one folder of <app>.png, owned by the platform
manifest_display = "browser"     # or "standalone"
```

Home-screen icons must be raster: an app whose icon is only an SVG, emoji or
monogram gets a favicon but no PNG. `enlace.app_icons.home_screen_gaps(config)`
lists those apps.

### Privacy-first analytics

An app can have page views counted with no cookie, no JavaScript and no third party. It opts in from its own `app.toml`:

```toml
[analytics]
mode = "privacy"
```

The enlace server counts the app's HTML page views as it serves them, and the pages are not changed at all. It stores only daily aggregates per app: views per path (query strings dropped), referrer domain, primary language and device class (mobile/tablet/desktop). Each is kept as a separate count, never crossed with the others. No IP address is read and no visitor identifier is kept. Bots are counted separately. `DNT: 1` and `Sec-GPC: 1` are honoured, and `/_analytics/opt-out` is a page a privacy notice can link to. An app without the table records nothing.

Read the counts on the serving host with `enlace analytics`, or from Python with `enlace.analytics_report()`. Storage defaults to JSON files under `~/.local/share/enlace/analytics`. It is configured in `platform.toml`, and `build_backend(config, analytics_store=...)` takes any `MutableMapping` (e.g. a `dol` store):

```toml
[analytics]
store_path = "~/.local/share/enlace/analytics"
retention_days = 395        # at most 750 (under 25 months)
timezone = "Europe/Paris"   # whose midnight starts a new day
```

The design and how it maps onto the CNIL's consent exemption for audience measurement are in [`misc/docs/privacy_analytics.md`](misc/docs/privacy_analytics.md). That doc also lists what a site operator still has to do: a privacy-notice entry, and handling their own access logs.

### Deploy manifest (`/_meta`)

enlace answers "what is actually deployed?" via an always-on, cheap manifest
layer. Every mounted app gets a `/_meta` endpoint (plus a platform-level
`/_meta`), and every response carries `X-Deploy-*` headers. A deploy tool
snapshots build identity (git SHA, deploy time, externals) at deploy time into
`{manifest_dir}/{app}.json`; enlace owns the read paths and schema. This lets
you tell whether the server is serving the SHA you think it is and whether the
browser rendered the current build or a stale cache.
