# Adopting dsh-config-form

A guide for plugin authors. Adopting gets your plugin a settings page without writing React, a route, storage, or a security check.

```sh
dsh plugin --profile <name> add dsh-config-form
```

## The whole integration

```ts
export const name = 'my-git-plugin'
export const inject = ['configForm']

export function apply(ctx: Context) {
  const config = ctx.configForm.declare(ctx, {
    id: 'my-git',
    title: { zh: 'Git', en: 'Git' },
    groups: [{
      title: { zh: '常规', en: 'General' },
      fields: [
        { kind: 'text', key: 'defaultBranch', label: { zh: '默认分支', en: 'Default branch' }, default: 'main' },
      ],
    }],
  })

  config.get().defaultBranch
}
```

Your plugin now has a row in **Settings → Plugins → Plugin configuration**, and the values persist. That is the entire integration.

## What adopting does NOT take over

This is a base plugin, not a framework. It claims one thing — the config you hand it — and nothing else.

| You keep writing your own | Why there is no conflict |
|---|---|
| **React / your own settings page** | Settings slots are independent ledgers. Your `settings.section` and this plugin's tab both render; neither knows about the other. Adopt for some fields and keep your own page for the rest if you want. |
| **Your own HTTP route** | `webServer.register` only throws on an *identical* path. `/my-plugin` and `/config-form` coexist. |
| **Your own admission / security checks** | Ours guards our route. Yours guards yours. Nothing is intercepted or wrapped. |
| **Your own storage, including for declared fields** | Pass a `store` and this plugin keeps nothing: it reads through your `read()`, routes saves to your `write()`, and renders. See [Storage](#storage). |
| **Your `cordis.yml` plugin `Config`** | Deployment config stays yours. Pass the parts a user may override as `base` (below) and they become the value a reset returns to. |

The one rule: **one owner per value.** Pick who stores each field and let that side own it.

## Storage

This plugin is a router and a renderer. Where the values live is your call.

**Your own store** — nothing is registered, nothing is kept here:

```ts
let values = loadFromMyFile()

ctx.configForm.declare(ctx, spec, {
  store: {
    read: () => values,                       // called on every page read; keep it cheap, no I/O
    write: (set, reset) => {                  // may be async
      values = { ...values, ...set }
      for (const key of reset) values[key] = myDefaults[key]
      saveToMyFile(values)
    },
  },
})
```

The page then reports `storage: "plugin"` and carries no revision and no `base`/`user` layers, because concurrency and "what unset means" are yours to define — so the override badges and per-field resets those facts drive are absent. Your declared constraints are still enforced before `write` is called, so you never receive a value your own declaration forbids. `watch` still fires, driven from the commit this plugin routed.

**The settings seam** — omit `store` and the values are kept for you:

```ts
ctx.configForm.declare(ctx, spec, { base: { defaultBranch: config.defaultBranch } })
```

That is the convenient default for a plugin with nowhere else to put them, and it brings layered resolution, revision fencing, external-edit reload, and per-field reset. Reported as `storage: "settings"`.

Either way you write no form, no route, and no browser code.

## What you get

**Layered resolution** (settings-seam mode). A value resolves as schema default → your `base` → the user's edit. A reset drops the user layer and falls back, so "restore defaults" needs no code from you.

```ts
ctx.configForm.declare(ctx, spec, {
  base: { defaultBranch: config.defaultBranch },   // your cordis.yml value
  applies: 'live',                                  // or 'restart', shown to the user
})
```

**Change notification.**

```ts
config.watch((next, previous) => {
  if (next.defaultBranch !== previous.defaultBranch) reconnect(next.defaultBranch)
})
```

Fires after a save commits and after an external edit to `settings.yaml`. Declare `applies: 'restart'` instead if you cannot apply changes live — the page then tells the user so.

**Write integrity, for free.** Saves travel as path operations fenced by a revision, so a stale page is refused rather than overwriting a concurrent change, and fields the page never saw cannot be clobbered.

## Fields

Five kinds. Every one takes `key`, `label`, and optional `description`.

```ts
{ kind: 'text',    key: 'host',   label: {…}, default?: string, placeholder?: string,
                                              required?: boolean, pattern?: string }
{ kind: 'number',  key: 'port',   label: {…}, default?: number, min?: number, max?: number,
                                              step?: number, required?: boolean }
{ kind: 'boolean', key: 'strict', label: {…}, default?: boolean }
{ kind: 'select',  key: 'mode',   label: {…}, options: [{ value, label }, …], default?: string }
{ kind: 'secret',  key: 'token',  label: {…}, ref: 'MY_PLUGIN_TOKEN' }
```

`label`, `description`, and group titles accept a plain string or a locale map (`{ zh, en }`). The active locale picks; English then any entry is the fallback.

### Groups carry the hierarchy

```ts
groups: [
  { title: { zh: '常规', en: 'General' }, fields: [ /* the two or three that matter */ ] },
  { title: { zh: '高级', en: 'Advanced' }, collapsed: true, fields: [ /* the rest */ ] },
]
```

`collapsed: true` is the only way to say "this is advanced". Use it. A generic renderer built into the harness was written and then removed precisely because schemas carry field truth with no visual hierarchy, and a flat wall of every field is unusable. Your grouping is what makes the form readable.

### Secrets

A `secret` field never enters `settings.yaml`. It declares a credential reference; the value lives in the credential store and is resolved only when you need it:

```ts
{ kind: 'secret', key: 'token', label: { zh: '访问令牌', en: 'Access token' }, ref: 'MY_PLUGIN_TOKEN' }
```

```ts
const token = await config.secret('token')   // undefined while unconfigured
```

The control is write-only: it renders empty, reports only whether a value is configured and which layer supplies it, and a blank box on save means "leave it alone" rather than "delete it". `ref` must be a POSIX identifier, the credential store's own rule.

Do not put a secret in a `text` field. Values in a settings section reach the browser.

## Validation

Everything the declaration can express is enforced before your code sees a value: types, `required`, `pattern`, `min`/`max`/`step`, and select membership. Cross-field rules the DSL cannot express stay yours to check in `watch`.

An invalid *declaration* throws at load, so your plugin fails with the reason rather than rendering a broken page. Rejected at declare time: a non-kebab-case `id`, no groups, a duplicate `key`, a key that is not an identifier, a select with no options or a default outside them, an unparseable `pattern`, a non-POSIX credential `ref`.

## Ownership and lifecycle

`declare(ctx, …)` takes your context explicitly because it decides ownership. The settings registration and your row on the page are effects on **your** fiber:

- your plugin unloads → your form disappears and its namespace is released;
- a hot reload can re-declare the same `id`;
- nothing leaks if your plugin fails to activate.

Pass the `ctx` your `apply` received. Not a child, not a captured outer one.

## Collisions to avoid

Three, all easy:

1. **Do not also call `ctx.settings.register()` with the same id** in settings-seam mode — the seam fails loud on a duplicate namespace. If you already register one, the cleanest adoption is a `store` that reads and writes through your existing scope, which leaves your registration untouched.
2. **`id` becomes a settings namespace**, so it must be unique across the whole composition and lowercase kebab-case. Prefix it with your plugin name.
3. **Migrating from your own storage?** Read your old value once at load and pass it as `base`, so a user who never edits keeps their current setting. Then stop writing that value yourself.

## Not supported yet

- **Arrays, dictionaries, nested objects.** If a field of yours is a list of objects, this cannot render it today. Declare the parts it can and keep that one field in your own page, or wait for the escape hatch that accepts a raw schemastery node.
- **Per-field error placement.** A rejected save reports the host's first message; it is not yet mapped onto the offending control.
- **Custom controls.** No slot for your own widget inside our form. Your own page remains the answer for anything bespoke.

None of these block partial adoption: declare what fits, keep the rest.

## Checklist

- [ ] `inject: ['configForm']`
- [ ] `id` prefixed with your plugin name, lowercase kebab-case
- [ ] Two or three fields in the first group, everything else behind `collapsed: true`
- [ ] Every `label` and `description` localized for the audiences you serve
- [ ] Secrets declared as `kind: 'secret'` with a POSIX `ref`, never as text
- [ ] Storage chosen: a `store` of your own, or the seam plus `base` so a reset returns to your deployment value
- [ ] `applies: 'restart'` if you cannot apply changes live
- [ ] Not writing any declared value through another path

## Full example

[`examples/demo-git-plugin.ts`](examples/demo-git-plugin.ts) — TypeScript, all five field kinds, a collapsed advanced group, a credential.
[`examples/demo-minimal.js`](examples/demo-minimal.js) — plain JavaScript, the smallest useful adoption.
