[deckle-hosted] Per-site features: add at creation, add later, remove, driven by the harness #145

Closed
opened 2026-10-04 17:32:08 +02:00 by dominik.polakovics · 1 comment

Summary

Let people switch deckle feature bundles (today: news) on and off per site — at site creation, at any later point, and off again — by driving the harness's features, add and remove commands. Hosted renders its Features UI entirely from what the configured harness reports, so it knows nothing about any specific feature.

Current behavior

  • Scaffolding a new site runs clone, harness init, commit, verification hugo --minify, push (gitserve scaffolder). No feature is ever added, and the new-site dialog has no Features group.
  • Nothing in hosted can change a site's sidecars, layouts or hugo.toml after creation. The editor's publish only carries content/ and assets/media/ to main, so feature changes can never come from the editor.
  • The per-site editor working copy is refreshed to main only on a cold start and only when it is completely clean; any unpublished draft blocks the refresh (instances worktree handling).

Any hardcoded handling of news that exists on a branch (feature names, labels, an edit of editor/pagetypes/home.yaml) is replaced by this work.

Desired behavior

Catalogue

At startup hosted runs the configured harness once with features --json and keeps the answer: each feature's name, label, description and version. If the command is unknown or fails (an older harness), hosted logs it once, shows no Features UI anywhere, and otherwise works as before.

At site creation

The new-site dialog shows a Features group — one checkbox per catalogue entry with its label and description — when "a working deckle site" is selected. The scaffold runs harness add <feature> --json for each ticked feature after harness init and before the initial commit, so the first commit already contains them and the verification build covers them. A failed add fails the scaffold like any other step, with its own step name and the harness's diagnostics.

On an existing site

A Features section on the site shows every catalogue entry and whether it is installed on the site's main (from harness features --site … --json, i.e. the receipts under editor/features/).

Adding runs in a clean temporary clone of main, exactly like the scaffold: harness add, commit as the platform identity, verification build, push. Nothing is pushed if the build fails. The push goes through the normal build pipeline. Outcomes shown to the user: added; already installed; conflict ("this site has its own files where the feature would go", with the paths); and a note when the harness reports that it could not allow the section on the home page type.

Removing (normal path)

  1. Hosted runs harness remove <feature> --json without --purge on a clean clone of main. This is a read-only preview.
  2. If the site has an editor working copy, hosted runs the same preview there. If any unpublished draft uses the feature (a page or element reported by the working-copy preview whose file differs from main), the remove is refused: the owner is asked to publish or discard those drafts first, with the pages named.
  3. Otherwise hosted shows a confirmation built from the preview — e.g. "this deletes 14 news pages, removes 2 elements from other pages and 1 customised template; 3 pages link to pages that will be gone" — or no confirmation detail when nothing is in use.
  4. On confirm: harness remove <feature> --purge --json in the clean clone, commit, verification build, push.

Removing (forced, operators only) — for when the operator must take a feature away regardless of the customer's work in progress:

  1. The editor working copy's full draft state is saved under a backup ref in the site's bare repository.
  2. The removal is pushed to main as in the normal path.
  3. harness remove <feature> --purge runs directly on the editor working copy, which deletes draft pages of the feature's types and strips the feature's elements out of other drafts while keeping their other edits.
  4. The working copy is moved onto the new main with all remaining drafts intact.

Keeping the editor in step

After any feature commit lands on main, the site's editor working copy is fast-forwarded to it even when it holds drafts, whenever the incoming commits and the drafts touch different files. An add only touches sidecars, layouts and config, so it always goes through; the owner who just enabled News finds it in the editor regardless of unrelated drafts. Drafts are never altered by this.

Decisions

  • Hosted commits, deckle does not — the harness commands only change files in a working tree and never touch git.
  • main is always changed in a clean temporary clone, verified by a build before the push — same guarantee the scaffold gives: main never receives a tree that does not build.
  • Hosted knows no feature by name — labels, descriptions, installed state, what a removal would delete: all from the harness's JSON. The same pinned binary scaffolds sites and runs in the editor images, so list and bundles cannot drift apart.
  • Remove purges content — pages and elements of the feature are deleted; git history is the way back. That reasoning only holds for content that is in git, hence the next two.
  • A normal remove never touches unpublished drafts — it is refused while drafts use the feature, because drafts exist nowhere else.
  • A forced remove backs drafts up into the bare repository first — so "it is still in git" stays true even when the operator overrides. Restoring from the backup is manual, like the site trash.
  • Forced remove is operator-only — an owner who hits the refusal can publish or discard the drafts in the editor; the operator is the one who must act without the customer.
  • Only what belongs to the feature is dropped — unrelated drafts survive a forced remove untouched.
  • Add must not be held up by unrelated drafts — hence the fast-forward of a dirty working copy rather than blocking feature changes while drafts exist.
  • No update of installed features and no adoption of pre-receipt sites — out of scope on the deckle side too; hosted may show installed vs. available version but offers no action on it.

Key interfaces

The harness contract (Cloonar/deckle#144). With --json each command prints exactly one JSON object on stdout carrying a format version. Exit 0: the change happened or was already in place. Exit 1: it did not, and the JSON says why. Exit 2: usage error, no JSON. Read the status first; treat the exit code as a cross-check.

harness features [--site <dir>] --json
harness add <feature> [--site <dir>] --json
harness remove <feature> [--site <dir>] [--purge] --json
  • features: name, label, description, version per feature; with --site also installed and installed version.
  • add: status added / already installed / conflict (with paths) / error; plus whether the page-type allowance was applied, already present, or skipped (with the reason).
  • remove without --purge: changes nothing when the feature is in use and reports status blocked with the usage: pages of the feature's types, elements on other pages, customised vendored files, pages linking to pages that would go. Guaranteed read-only on any tree, including one with uncommitted changes.
  • remove --purge: status removed, with what was purged and which config settings were kept because the owner had changed them. Works identically on a clean clone and on a working copy with uncommitted changes.
  • A site's installed features are recorded as receipt files under editor/features/ and are committed with the site.

Starting hints in hosted: the gitserve scaffolder (clone / init / commit / build / push steps and ScaffoldError), the instances worktree handling (ensureWorktree, refreshWorktree), the config's harness_path and operators, and the new-site dialog.

Acceptance criteria

  • With a harness that supports features --json, the new-site dialog lists every reported feature with its label and description; with one that does not, the dialog and site pages show no Features UI and nothing else breaks.
  • Creating a site with a feature ticked yields an initial commit that contains the feature's files and its receipt, and the feature is usable in the editor without any manual step.
  • A failing harness add during scaffold fails the creation with a named step and leaves the bare repository empty.
  • An existing site's Features section reflects the receipts on main.
  • Adding a feature to an existing site produces one commit on main by the platform identity, only after a successful verification build, and triggers the normal site build.
  • A conflict reported by the harness is shown with its paths and nothing is pushed.
  • Removing a feature that is in use shows the preview before anything changes; confirming produces one commit on main and a successful build.
  • A normal remove is refused, naming the pages, while an unpublished draft in the editor working copy uses the feature; nothing is pushed and no draft is touched.
  • A forced remove is available to operators only; it leaves a backup ref in the bare repository containing the drafts as they were, removes the feature from main, and leaves the editor working copy on the new main with unrelated drafts intact and no page or element of the feature left.
  • After a feature is added while the editor working copy holds an unrelated draft, the editor offers the feature and the draft is unchanged.
  • A push rejected because main moved in the meantime (e.g. an editor publish) is retried from a fresh clone or reported; it never leaves a half-applied state.
  • No feature name, label or path of a specific feature appears in hosted's code.

How to verify

  • The repository's Go tests, plus the real-binary tests with a harness built from a deckle that includes Cloonar/deckle#144 (DECKLE_HOSTED_TEST_HARNESS, DECKLE_HOSTED_TEST_HUGO).
  • By hand on the dev platform: create a site with News ticked; create a second site without it, add News later while a draft exists on another page; create news items and a "Latest news" element; remove News normally (refused while a news draft exists, then confirmed after publishing); repeat with a draft in place using the forced remove as an operator and inspect the backup ref.

Assumptions to check

  • That fast-forwarding a working copy with uncommitted changes succeeds whenever the incoming commit touches none of the changed files, and fails cleanly without touching anything otherwise.
  • That a running editor instance picks up new or removed sidecars, layouts and hugo.toml changes without a restart (the harness reads sidecars per request; hugo server config reload is the part nobody verified).
  • That after running the purge on the working copy, moving it onto the new main leaves exactly the remaining drafts as differences.
  • How the editor working copy relates to the harness's own local snapshot ref, and whether the backup can reuse it.
  • Whether installed state is cheaper to read via a temporary checkout or straight from the receipts in the bare repository.

Decisions not yet made

  • Who may add and remove features on the normal path. Default assumed here: whoever may create sites in the organization. If features are billed, this may need to be operator-only or tied to a plan — that is a product decision for the maintainer.

Out of scope

  • Billing or entitlements for features.
  • Hosted-only features that are not part of the deckle binary.
  • Updating an installed feature to a newer version.
  • A restore command for the draft backup.
  • General syncing of developer pushes into editor working copies beyond what the fast-forward above gives for free.

Blocked by

## Summary Let people switch deckle feature bundles (today: news) on and off per site — at site creation, at any later point, and off again — by driving the harness's `features`, `add` and `remove` commands. Hosted renders its Features UI entirely from what the configured harness reports, so it knows nothing about any specific feature. ## Current behavior - Scaffolding a new site runs clone, `harness init`, commit, verification `hugo --minify`, push (`gitserve` scaffolder). No feature is ever added, and the new-site dialog has no Features group. - Nothing in hosted can change a site's sidecars, layouts or `hugo.toml` after creation. The editor's publish only carries `content/` and `assets/media/` to `main`, so feature changes can never come from the editor. - The per-site editor working copy is refreshed to `main` only on a cold start and only when it is completely clean; any unpublished draft blocks the refresh (`instances` worktree handling). Any hardcoded handling of `news` that exists on a branch (feature names, labels, an edit of `editor/pagetypes/home.yaml`) is replaced by this work. ## Desired behavior ### Catalogue At startup hosted runs the configured harness once with `features --json` and keeps the answer: each feature's name, label, description and version. If the command is unknown or fails (an older harness), hosted logs it once, shows no Features UI anywhere, and otherwise works as before. ### At site creation The new-site dialog shows a Features group — one checkbox per catalogue entry with its label and description — when "a working deckle site" is selected. The scaffold runs `harness add <feature> --json` for each ticked feature after `harness init` and before the initial commit, so the first commit already contains them and the verification build covers them. A failed add fails the scaffold like any other step, with its own step name and the harness's diagnostics. ### On an existing site A Features section on the site shows every catalogue entry and whether it is installed on the site's `main` (from `harness features --site … --json`, i.e. the receipts under `editor/features/`). **Adding** runs in a clean temporary clone of `main`, exactly like the scaffold: `harness add`, commit as the platform identity, verification build, push. Nothing is pushed if the build fails. The push goes through the normal build pipeline. Outcomes shown to the user: added; already installed; conflict ("this site has its own files where the feature would go", with the paths); and a note when the harness reports that it could not allow the section on the home page type. **Removing (normal path)** 1. Hosted runs `harness remove <feature> --json` without `--purge` on a clean clone of `main`. This is a read-only preview. 2. If the site has an editor working copy, hosted runs the same preview there. If any unpublished draft uses the feature (a page or element reported by the working-copy preview whose file differs from `main`), the remove is refused: the owner is asked to publish or discard those drafts first, with the pages named. 3. Otherwise hosted shows a confirmation built from the preview — e.g. "this deletes 14 news pages, removes 2 elements from other pages and 1 customised template; 3 pages link to pages that will be gone" — or no confirmation detail when nothing is in use. 4. On confirm: `harness remove <feature> --purge --json` in the clean clone, commit, verification build, push. **Removing (forced, operators only)** — for when the operator must take a feature away regardless of the customer's work in progress: 1. The editor working copy's full draft state is saved under a backup ref in the site's bare repository. 2. The removal is pushed to `main` as in the normal path. 3. `harness remove <feature> --purge` runs directly on the editor working copy, which deletes draft pages of the feature's types and strips the feature's elements out of other drafts while keeping their other edits. 4. The working copy is moved onto the new `main` with all remaining drafts intact. ### Keeping the editor in step After any feature commit lands on `main`, the site's editor working copy is fast-forwarded to it **even when it holds drafts**, whenever the incoming commits and the drafts touch different files. An add only touches sidecars, layouts and config, so it always goes through; the owner who just enabled News finds it in the editor regardless of unrelated drafts. Drafts are never altered by this. ## Decisions - **Hosted commits, deckle does not** — the harness commands only change files in a working tree and never touch git. - **`main` is always changed in a clean temporary clone, verified by a build before the push** — same guarantee the scaffold gives: `main` never receives a tree that does not build. - **Hosted knows no feature by name** — labels, descriptions, installed state, what a removal would delete: all from the harness's JSON. The same pinned binary scaffolds sites and runs in the editor images, so list and bundles cannot drift apart. - **Remove purges content** — pages and elements of the feature are deleted; git history is the way back. That reasoning only holds for content that is in git, hence the next two. - **A normal remove never touches unpublished drafts** — it is refused while drafts use the feature, because drafts exist nowhere else. - **A forced remove backs drafts up into the bare repository first** — so "it is still in git" stays true even when the operator overrides. Restoring from the backup is manual, like the site trash. - **Forced remove is operator-only** — an owner who hits the refusal can publish or discard the drafts in the editor; the operator is the one who must act without the customer. - **Only what belongs to the feature is dropped** — unrelated drafts survive a forced remove untouched. - **Add must not be held up by unrelated drafts** — hence the fast-forward of a dirty working copy rather than blocking feature changes while drafts exist. - **No update of installed features and no adoption of pre-receipt sites** — out of scope on the deckle side too; hosted may show installed vs. available version but offers no action on it. ## Key interfaces The harness contract (Cloonar/deckle#144). With `--json` each command prints exactly one JSON object on stdout carrying a format version. Exit `0`: the change happened or was already in place. Exit `1`: it did not, and the JSON says why. Exit `2`: usage error, no JSON. Read the status first; treat the exit code as a cross-check. ``` harness features [--site <dir>] --json harness add <feature> [--site <dir>] --json harness remove <feature> [--site <dir>] [--purge] --json ``` - `features`: name, label, description, version per feature; with `--site` also installed and installed version. - `add`: status added / already installed / conflict (with paths) / error; plus whether the page-type allowance was applied, already present, or skipped (with the reason). - `remove` without `--purge`: changes nothing when the feature is in use and reports status blocked with the usage: pages of the feature's types, elements on other pages, customised vendored files, pages linking to pages that would go. Guaranteed read-only on any tree, including one with uncommitted changes. - `remove --purge`: status removed, with what was purged and which config settings were kept because the owner had changed them. Works identically on a clean clone and on a working copy with uncommitted changes. - A site's installed features are recorded as receipt files under `editor/features/` and are committed with the site. Starting hints in hosted: the `gitserve` scaffolder (clone / init / commit / build / push steps and `ScaffoldError`), the `instances` worktree handling (`ensureWorktree`, `refreshWorktree`), the config's `harness_path` and `operators`, and the new-site dialog. ## Acceptance criteria - [ ] With a harness that supports `features --json`, the new-site dialog lists every reported feature with its label and description; with one that does not, the dialog and site pages show no Features UI and nothing else breaks. - [ ] Creating a site with a feature ticked yields an initial commit that contains the feature's files and its receipt, and the feature is usable in the editor without any manual step. - [ ] A failing `harness add` during scaffold fails the creation with a named step and leaves the bare repository empty. - [ ] An existing site's Features section reflects the receipts on `main`. - [ ] Adding a feature to an existing site produces one commit on `main` by the platform identity, only after a successful verification build, and triggers the normal site build. - [ ] A conflict reported by the harness is shown with its paths and nothing is pushed. - [ ] Removing a feature that is in use shows the preview before anything changes; confirming produces one commit on `main` and a successful build. - [ ] A normal remove is refused, naming the pages, while an unpublished draft in the editor working copy uses the feature; nothing is pushed and no draft is touched. - [ ] A forced remove is available to operators only; it leaves a backup ref in the bare repository containing the drafts as they were, removes the feature from `main`, and leaves the editor working copy on the new `main` with unrelated drafts intact and no page or element of the feature left. - [ ] After a feature is added while the editor working copy holds an unrelated draft, the editor offers the feature and the draft is unchanged. - [ ] A push rejected because `main` moved in the meantime (e.g. an editor publish) is retried from a fresh clone or reported; it never leaves a half-applied state. - [ ] No feature name, label or path of a specific feature appears in hosted's code. ## How to verify - The repository's Go tests, plus the real-binary tests with a harness built from a deckle that includes Cloonar/deckle#144 (`DECKLE_HOSTED_TEST_HARNESS`, `DECKLE_HOSTED_TEST_HUGO`). - By hand on the dev platform: create a site with News ticked; create a second site without it, add News later while a draft exists on another page; create news items and a "Latest news" element; remove News normally (refused while a news draft exists, then confirmed after publishing); repeat with a draft in place using the forced remove as an operator and inspect the backup ref. ## Assumptions to check - That fast-forwarding a working copy with uncommitted changes succeeds whenever the incoming commit touches none of the changed files, and fails cleanly without touching anything otherwise. - That a running editor instance picks up new or removed sidecars, layouts and `hugo.toml` changes without a restart (the harness reads sidecars per request; `hugo server` config reload is the part nobody verified). - That after running the purge on the working copy, moving it onto the new `main` leaves exactly the remaining drafts as differences. - How the editor working copy relates to the harness's own local snapshot ref, and whether the backup can reuse it. - Whether installed state is cheaper to read via a temporary checkout or straight from the receipts in the bare repository. ## Decisions not yet made - **Who may add and remove features on the normal path.** Default assumed here: whoever may create sites in the organization. If features are billed, this may need to be operator-only or tied to a plan — that is a product decision for the maintainer. ## Out of scope - Billing or entitlements for features. - Hosted-only features that are not part of the deckle binary. - Updating an installed feature to a newer version. - A restore command for the draft backup. - General syncing of developer pushes into editor working copies beyond what the fast-forward above gives for free. ## Blocked by - Cloonar/deckle#144
Author
Owner

Parked here for transfer: this brief is for the deckle-hosted repository and is not to be implemented in deckle. It is labelled ready-for-human so no agent picks it up here. The body is written to be copied over as-is; it depends on #144. Close this issue once it has been copied.

Parked here for transfer: this brief is for the deckle-hosted repository and is not to be implemented in deckle. It is labelled ready-for-human so no agent picks it up here. The body is written to be copied over as-is; it depends on #144. Close this issue once it has been copied.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#145
No description provided.