Extract typed form system from models/workflow.go into standalone server/forms/ package. Any package can now declare and validate forms, not just workflow stages. - New server/forms/ package (types + validation + condition evaluator) - REST POST /api/v1/forms/validate endpoint - Starlark forms.validate(template, data) module - Frontend sw.forms.render(), .validate(), .validateRemote() - Manifest form_template accepted at package level - 16 new tests (12 forms + 4 Starlark module) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
255 lines
8.6 KiB
Markdown
255 lines
8.6 KiB
Markdown
# Package Format
|
|
|
|
Armature packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
|
|
|
|
## ZIP Structure
|
|
|
|
```
|
|
my-package.pkg
|
|
├── manifest.json # required -- package metadata
|
|
├── js/ # browser-side JavaScript
|
|
│ └── index.js
|
|
├── css/ # stylesheets
|
|
│ └── styles.css
|
|
├── script.star # Starlark entry point
|
|
├── star/ # additional Starlark modules
|
|
├── assets/ # static assets (images, etc.)
|
|
└── migrations/ # schema migration files
|
|
```
|
|
|
|
Only `manifest.json` is required. All other directories are optional and included only if present in the source.
|
|
|
|
## manifest.json Reference
|
|
|
|
```json
|
|
{
|
|
"id": "my-package",
|
|
"title": "My Package",
|
|
"type": "surface",
|
|
"tier": "browser",
|
|
"version": "1.0.0",
|
|
"description": "What this package does.",
|
|
"icon": "📦",
|
|
"author": "your-name",
|
|
"route": "/s/my-package",
|
|
"auth": "authenticated",
|
|
"permissions": [],
|
|
"api_routes": [],
|
|
"db_tables": {},
|
|
"settings": {},
|
|
"hooks": {},
|
|
"exports": [],
|
|
"schema_version": 1
|
|
}
|
|
```
|
|
|
|
### Required Fields
|
|
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| `id` | Unique kebab-case identifier |
|
|
| `title` | Human-readable display name |
|
|
| `type` | `surface`, `extension`, `full`, `library`, `workflow` |
|
|
| `tier` | `browser`, `starlark`, `sidecar` |
|
|
| `version` | Semver version string |
|
|
|
|
### Optional Fields
|
|
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| `description` | Short description |
|
|
| `icon` | Emoji for sidebar/menu display |
|
|
| `author` | Package author |
|
|
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
|
|
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
|
|
| `auth` | Default access level for all surfaces: `authenticated`, `public`, `admin` |
|
|
| `layout` | Default layout for all surfaces: `single`, `editor` |
|
|
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
|
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
|
| `db_tables` | Table definitions with columns and indexes |
|
|
| `settings` | User-configurable settings with type, label, description, default |
|
|
| `hooks` | Event bus subscription patterns |
|
|
| `exports` | Functions exported for cross-package calls via `lib.require()` |
|
|
| `depends` | Array of package IDs this package depends on |
|
|
| `slots` | Named UI injection points (host surfaces declare these) |
|
|
| `contributes` | Slot contributions this package injects into other surfaces |
|
|
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
|
| `schema_version` | Integer for additive schema migrations |
|
|
| `form_template` | Typed form template (v0.9.5). Fields array or fieldsets for progressive forms. Validated at install. |
|
|
|
|
## Multi-Surface Packages
|
|
|
|
A package can serve multiple pages, each with its own path, access level,
|
|
title, and layout. Declare a `surfaces` array in the manifest:
|
|
|
|
```json
|
|
{
|
|
"id": "bug-tracker",
|
|
"title": "Bug Tracker",
|
|
"type": "full",
|
|
"auth": "authenticated",
|
|
"layout": "single",
|
|
"surfaces": [
|
|
{ "path": "/", "title": "Dashboard" },
|
|
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
|
|
{ "path": "/:id", "title": "Bug Detail", "nav": false },
|
|
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
|
|
]
|
|
}
|
|
```
|
|
|
|
### Surface Entry Fields
|
|
|
|
| Field | Type | Default | Description |
|
|
|----------|--------|----------------|-------------|
|
|
| `path` | string | **required** | Relative to `/s/{pkg-id}`. Supports `:param` segments. |
|
|
| `access` | string | package `auth` | `public`, `authenticated`, `admin`, `group:{name}`. |
|
|
| `title` | string | package `title`| Human label. Used in nav if `nav: true`. |
|
|
| `layout` | string | package `layout`| `single`, `editor`, or future layouts. |
|
|
| `nav` | bool | see rules | Show in sidebar navigation. |
|
|
|
|
### Nav Visibility Rules
|
|
|
|
- `path: "/"` defaults to `nav: true` (primary entry point)
|
|
- All others default to `nav: false` (sub-pages)
|
|
- Explicit `"nav": true` overrides — a package can put multiple entries in nav
|
|
- The sidebar links to the first surface with `nav: true`
|
|
|
|
### Backward Compatibility
|
|
|
|
If `surfaces` is absent, the kernel synthesizes one entry from the legacy
|
|
`auth` and `layout` fields. No existing packages break.
|
|
|
|
### Client-Side Navigation
|
|
|
|
Within a multi-surface package, use `sw.navigate()` for SPA-style routing:
|
|
|
|
```js
|
|
const path = window.__SURFACE_PATH__ || '/';
|
|
const params = window.__SURFACE_PARAMS__ || {};
|
|
|
|
if (path === '/') renderDashboard();
|
|
if (path === '/submit') renderSubmitForm();
|
|
if (path === '/:id') renderDetail(params.id);
|
|
|
|
// Navigate to another surface within the package
|
|
sw.navigate('/submit');
|
|
sw.navigate('/:id', { id: 'bug-42' });
|
|
|
|
// Listen for navigation events (including back/forward)
|
|
sw.on('surface.navigate', ({ path, params }) => {
|
|
renderView(path, params);
|
|
});
|
|
```
|
|
|
|
## Composability: Slots and Contributions
|
|
|
|
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
|
|
|
|
### Declaring Slots (Host Surfaces)
|
|
|
|
Surfaces declare slots where other extensions can inject UI:
|
|
|
|
```json
|
|
{
|
|
"slots": {
|
|
"toolbar-actions": {
|
|
"description": "Toolbar action buttons",
|
|
"context": {
|
|
"noteId": "string — current note ID",
|
|
"getContent": "function — returns note body text"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Slot names are namespaced at runtime as `{package-id}:{slot-name}` (e.g., `notes:toolbar-actions`). The `context` field documents what data the slot provides — it is not enforced at runtime.
|
|
|
|
### Contributing to Slots
|
|
|
|
Extensions declare which slots they inject into:
|
|
|
|
```json
|
|
{
|
|
"contributes": {
|
|
"notes:toolbar-actions": {
|
|
"label": "Dictate",
|
|
"icon": "🎤",
|
|
"description": "Voice-to-text dictation"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at `GET /api/v1/admin/slots`.
|
|
|
|
### Cross-Package Function Calls
|
|
|
|
Any package that declares `exports` can be called via `lib.require()`, not just library-type packages. The caller must declare the target in its `depends` array:
|
|
|
|
```json
|
|
{
|
|
"depends": ["image-gen"],
|
|
"permissions": ["api.http"]
|
|
}
|
|
```
|
|
|
|
## Package Lifecycle
|
|
|
|
1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes.
|
|
|
|
2. **Enable**: Activate the package so its routes, hooks, and UI become available. `PUT /api/v1/admin/packages/:id/enable`.
|
|
|
|
3. **Disable**: Deactivate without removing data. `PUT /api/v1/admin/packages/:id/disable`.
|
|
|
|
4. **Update**: Upload a new `.pkg` with a higher semver version. Schema changes must be additive (new columns/tables only). `POST /api/v1/admin/packages/:id/update`.
|
|
|
|
5. **Export**: Download the installed package as a `.pkg` archive. `GET /api/v1/admin/packages/:id/export`.
|
|
|
|
6. **Delete**: Remove the package and its data. `DELETE /api/v1/admin/packages/:id`.
|
|
|
|
## Bundled vs User-Installed
|
|
|
|
**Bundled packages** ship inside the Docker image at `/app/bundled-packages`. On first boot, a curated default set is auto-installed. Behavior:
|
|
|
|
- First boot: curated defaults are installed and enabled.
|
|
- Subsequent boots: already-installed packages are skipped.
|
|
- Admin uninstalls: the package stays uninstalled (never force-reinstalled).
|
|
- Control via `BUNDLED_PACKAGES` env var: empty = defaults, `"*"` = all, or comma-separated IDs.
|
|
|
|
**User-installed packages** are uploaded through the Admin UI or API. They follow the same lifecycle but are not tied to the Docker image.
|
|
|
|
## Building Packages
|
|
|
|
Packages are built by zipping the standard directories alongside `manifest.json`:
|
|
|
|
```bash
|
|
# Build all packages in the packages/ directory
|
|
cd packages && bash build.sh
|
|
|
|
# Build a single package
|
|
cd packages && bash build.sh my-package
|
|
```
|
|
|
|
Output goes to `dist/my-package.pkg`.
|
|
|
|
### Manual Build
|
|
|
|
```bash
|
|
cd packages/my-package
|
|
zip -r ../../dist/my-package.pkg manifest.json js/ css/ script.star
|
|
```
|
|
|
|
The build script automatically includes whichever standard directories exist: `js/`, `css/`, `assets/`, `script.star`, `star/`, `migrations/`.
|
|
|
|
### Custom Docker Image
|
|
|
|
To bundle custom packages into a Docker image:
|
|
|
|
1. Place your package in `packages/your-package/` with a `manifest.json`.
|
|
2. Run `cd packages && bash build.sh all`.
|
|
3. Build the image: `docker build -t my-armature .`
|
|
|
|
The Dockerfile builds all packages and copies them into the bundled packages directory.
|