uniweb 0.83.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.83.0",
3
+ "version": "0.84.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/runtime": "^0.29.1",
46
- "@uniweb/kit": "^0.19.17",
47
- "@uniweb/schemas": "^0.12.0",
48
- "@uniweb/core": "^0.37.1",
49
- "@uniweb/semantic-parser": "^1.5.1"
45
+ "@uniweb/kit": "^0.19.18",
46
+ "@uniweb/core": "^0.37.2",
47
+ "@uniweb/semantic-parser": "^1.5.1",
48
+ "@uniweb/runtime": "^0.29.2",
49
+ "@uniweb/schemas": "^0.12.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.76.1",
52
+ "@uniweb/build": "^0.77.0",
53
53
  "@uniweb/content-reader": "^1.2.8",
54
54
  "@uniweb/semantic-parser": "^1.5.1"
55
55
  },
@@ -2012,16 +2012,16 @@ A form gets its destination from the first of these that applies:
2012
2012
 
2013
2013
  1. **One the host supplies** — `services.submit` in the served payload. Where the
2014
2014
  host handles submissions, that is the destination and nothing in `site.yml`
2015
- overrides it — so a site published to Uniweb Cloud normally needs **no
2016
- `submit:` at all**.
2015
+ overrides it — so a site published to Uniweb Cloud needs **no `submit:`**: it
2016
+ asks for form handling with `services:` instead (*Uniweb Cloud*, below).
2017
2017
  2. **`submit:` in `site.yml`** — an endpoint you name yourself, for a host that
2018
2018
  does not handle submissions, or a static site.
2019
- 3. **Neither** — there is no destination, and the form says so instead of
2020
- guessing at one.
2019
+ 3. **Neither** — there is no destination: render no form, or fall back to contact
2020
+ details the site already carries.
2021
2021
 
2022
- That is the general arrangement, not a forms-only one. A host declares
2023
- everything it offers under `services`, keyed by name, and every service resolves
2024
- by the same rule — the host's offer, then your declaration, then neither.
2022
+ That is the general arrangement, not a forms-only one. A host states everything
2023
+ it offers in the served payload's `services`, keyed by name, and every service
2024
+ resolves by the same rule — the host's offer, then your declaration, then neither.
2025
2025
 
2026
2026
  ⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
2027
2027
  no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isApiEnabled()`, `isAssistantEnabled()`,
@@ -2138,9 +2138,9 @@ if (!canSubmit) return null // nowhere to send — render no form, or fall ba
2138
2138
  ```
2139
2139
 
2140
2140
  > **The framework never invents an endpoint — but a host may supply one.** Don't
2141
- > reach for `submit:` reflexively: on Uniweb Cloud it is redundant, and setting
2142
- > it there overrides what the platform provides. Reach for it when you are the
2143
- > one hosting.
2141
+ > reach for `submit:` reflexively: on Uniweb Cloud the host's form handling wins
2142
+ > wherever it is on, so a `submit:` there answers only when it is off — ask for it
2143
+ > with `services:` instead. Reach for `submit:` when you are the one hosting.
2144
2144
  >
2145
2145
  > `canSubmit` is false only when neither a declaration nor a host supplies a
2146
2146
  > destination. **Check it when you render, not only on the button press** — a
@@ -2657,6 +2657,24 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2657
2657
 
2658
2658
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2659
2659
 
2660
+ **`services:` in `site.yml` asks your host for services** — site search, form handling, accounts:
2661
+
2662
+ ```yaml
2663
+ services:
2664
+ search: true # turn it on
2665
+ submit: false # turn it off
2666
+ api: # turn it on, with the service's own settings
2667
+ grade: pro
2668
+ ```
2669
+
2670
+ A service you leave out keeps whatever the site has, and settings you leave out keep theirs — to turn
2671
+ one off, say `false`. `uniweb push` and `uniweb publish` send what you changed since your last sync;
2672
+ if the site's services changed elsewhere in the meantime — an author in the app — they offer to update
2673
+ `site.yml` rather than send your older choice over it, and if both changed the same service they ask
2674
+ which to keep. `uniweb pull` writes what the site has into `services:`. A change that needs payment is
2675
+ settled in the app: `publish` opens it. ⛔ **Not the same as `search:` / `submit:`**, which configure a
2676
+ provider the site brings itself — and `services:` never reaches the built site.
2677
+
2660
2678
  **The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
2661
2679
 
2662
2680
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
@@ -1,68 +1,51 @@
1
1
  /**
2
- * The services request — is the file ASKING for something, or just carrying an
3
- * old answer?
2
+ * The requests a push or publish carries — what the owner asks for, and whether it
3
+ * is new.
4
4
  *
5
- * ## The defect this exists to close
5
+ * Two of them, kept by two mechanisms:
6
6
  *
7
- * `$services` / `$secrets` are Sections of the site-content document, so they ride
8
- * inside **every** push — the block is emitted whenever the key exists in
9
- * `site.yml`. Editing one paragraph on one page therefore re-sends the whole
10
- * request block.
7
+ * - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
8
+ * 2026-10-06]*. Sent as the site's own list with the owner's CHANGED asks applied,
9
+ * decided per service by comparing the file, the site now and the record of the
10
+ * last agreement in `sync.json` (`settleServices`; spec:
11
+ * kb/framework/reference/site-services-request.md).
12
+ * - **The language selection** — `site.yml::publishLanguages` — told apart from the
13
+ * status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
11
14
  *
12
- * ⛔ And the backend REPLACES what it is sent: a row anchors by its natural key and
13
- * is updated in place (`SectionScope::DeclaredOnly`; backend's
14
- * `uuidless_records_anchor_by_natural_key_and_keep_the_stored_uuid`, written
15
- * against this emitter's shape). So a re-send is not a harmless echo — it
16
- * **overwrites the stored request**, including a decision the owner made in the
17
- * app, which is where the consent workflow's publish happens.
15
+ * ⭐ The model both follow — *"the services in `site.yml` are a request, never a
16
+ * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file. An
17
+ * unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
18
+ * re-send would overwrite a decision the owner made in the app since.
18
19
  *
19
- * ⭐ Under the model the file follows — *"the services in `site.yml` are a request,
20
- * never a tracking of what is running"* [Diego, 2026-09-05] — **re-sending an
21
- * unchanged block is making a request nobody made.** You ask by CHANGING the file.
22
- *
23
- * ⚠️ It was mostly inert until 2026-09-08: `enabled` was honoured for `api` alone,
24
- * so a stale re-send of the other names overwrote rows nothing read. That stopped
25
- * being true the same day, and `api` was never inert — an `enabled: false` on it
26
- * with a live plan schedules a paid service to end.
27
- *
28
- * ## What "changed" is measured against
29
- *
30
- * The last block we are known to have sent, recorded in **`deploy.yml`** — a
31
- * COMMITTED project file that travels with a clone.
32
- *
33
- * ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
34
- * wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
35
- * has no base at all — and this workflow is multi-machine by construction (consent
36
- * in a browser, publish from the app). A base that vanishes turns this gate into
37
- * either the original defect or a silently dropped request.
38
- *
39
- * ## ⛔ A HASH, never the block
40
- *
41
- * `$secrets` names every secret the site has (values are the `#ref` marker, never
42
- * secret material — the old wording here said otherwise), and `$services[].config` is opaque and
43
- * per-service — anything may be in it. `deploy.yml` is committed, so recording
44
- * either verbatim would write them into git. Equality is all this gate needs;
45
- * *what* differs is a question for the backend's own copy of the request, not for
46
- * a mirror of our own.
20
+ * ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
21
+ * nobody edits, so a CLI user could ask for nothing; and this module compared them by
22
+ * a fingerprint in `deploy.yml`, which could not tell who moved.*
47
23
  *
48
24
  * @module
49
25
  */
50
26
 
51
27
  import { createHash } from 'node:crypto'
28
+ import {
29
+ readServicesRequest,
30
+ takeServices,
31
+ mergeServiceRows,
32
+ reconcileServices,
33
+ recordAfter,
34
+ readBackendState,
35
+ updateBackendState,
36
+ writeSiteConfig
37
+ } from '@uniweb/build/uwx'
52
38
 
53
39
  /**
54
- * A stable fingerprint of one declared block, or `null` when the key is absent.
40
+ * A stable fingerprint of one declared value, or `null` when the key is absent.
55
41
  *
56
- * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: absent
57
- * means *"I am not telling you about this"* and `[]` is an explicit clear. This
58
- * function only has to preserve that distinction — the destructive difference
59
- * between them is the backend's.
42
+ * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
43
+ * language selection, no key means "every declared language" and `[]` means none.
60
44
  *
61
- * Rows are ordered by their serialized form and object keys sorted, so reordering
62
- * rows or keys in the file is not a change. It is not a request to move a line.
45
+ * List entries are ordered by their serialized form and object keys sorted, so
46
+ * reordering them in the file is not a change.
63
47
  *
64
- * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
- * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
48
+ * @param {*} declared
66
49
  * @returns {string|null} 16 hex chars, or null when undeclared
67
50
  */
68
51
  export function fingerprintDeclaration(declared) {
@@ -70,13 +53,10 @@ export function fingerprintDeclaration(declared) {
70
53
  const canonical = Array.isArray(declared)
71
54
  ? declared.map(stableString).sort()
72
55
  : [stableString(declared)]
73
- return createHash('sha256')
74
- .update(JSON.stringify(canonical))
75
- .digest('hex')
76
- .slice(0, 16)
56
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
77
57
  }
78
58
 
79
- /** Absent, or an empty list — a stored request that asks for nothing. */
59
+ /** Absent, or an empty list — a stored value that asks for nothing. */
80
60
  function isNothing(value) {
81
61
  return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
82
62
  }
@@ -91,125 +71,22 @@ function stableString(value) {
91
71
  }
92
72
 
93
73
  /**
94
- * Both blocks' fingerprints, in the shape `deploy.yml::lastDeploy.<target>` keeps
95
- * them. Keys are omitted rather than set to null, so an undeclared block leaves no
96
- * trace in the file.
97
- *
98
- * @param {object} siteYml
99
- * @returns {{servicesRequest?: string, secretsRequest?: string}}
100
- */
101
- export function fingerprintRequest(siteYml, provisioned = {}) {
102
- const out = {}
103
- // ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
104
- // backend being published to (they were `site.yml::$services` / `$secrets` until
105
- // 2026-09-20). The language selection below is still authored, so still site.yml.
106
- const services = fingerprintDeclaration(provisioned?.services)
107
- if (services) out.servicesRequest = services
108
- const secrets = fingerprintDeclaration(provisioned?.secrets)
109
- if (secrets) out.secretsRequest = secrets
110
- // ⭐ The language selection is a request too, and it is the one that moves a
111
- // PRICE — the line is billed on how many languages go out. Banking it is what
112
- // lets a later edit read as "the owner asked for another language" rather than
113
- // as a value that was always there.
114
- const langs = fingerprintDeclaration(siteYml?.publishLanguages)
115
- if (langs) out.publishLanguagesRequest = langs
116
- return out
117
- }
118
-
119
- /**
120
- * Should this push DECLARE the request blocks?
121
- *
122
- * ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
123
- * that decides it:
124
- *
125
- * 1. It is the behaviour every CLI has had, so nothing regresses.
126
- * 2. A project may legitimately have no `deploy.yml` (never published, or
127
- * `autoSave: off`), and that is not evidence about the request.
128
- * 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
129
- * failure directions, sending an unchanged block writes back what is usually
130
- * already there, while withholding a changed one leaves an owner's edit with
131
- * no effect and nothing said. The loud failure is the better one.
132
- *
133
- * @param {object} siteYml - the parsed site.yml
134
- * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
135
- * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
136
- */
137
- export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
138
- const now = fingerprintRequest(siteYml, provisioned)
139
- if (!now.servicesRequest && !now.secretsRequest) {
140
- // Nothing in the file to send. The gate is moot; say so rather than
141
- // reporting "unchanged", which would imply a comparison happened.
142
- return { declare: true, reason: 'undeclared' }
143
- }
144
- if (!lastDeploy || typeof lastDeploy !== 'object') {
145
- return { declare: true, reason: 'no-record' }
146
- }
147
- const same =
148
- now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
149
- now.secretsRequest === (lastDeploy.secretsRequest || undefined)
150
- return same
151
- ? { declare: false, reason: 'unchanged' }
152
- : { declare: true, reason: 'changed' }
153
- }
154
-
155
- /**
156
- * The four-way reconcile, once the backend's own copy of the request is in hand.
157
- *
158
- * ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
159
- * compares the file to our MEMORY of what we last sent, which leaves a window: a
160
- * request changed in the app between a `uniweb pull` and the next publish reads as
161
- * unchanged-from-nothing. Comparing to the backend's own rows closes it, because
162
- * the base stops being something we have to remember correctly.
163
- *
164
- * ⛔ It still needs the banked fingerprint. Local and remote differing says the two
165
- * disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
166
- * the difference between "the app decided, adopt it" and "you edited, send it".
167
- *
168
- * ## The four outcomes
169
- *
170
- * | local | remote | |
171
- * |---|---|---|
172
- * | unchanged | unchanged | `none` — nobody asked anything |
173
- * | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
174
- * | **edited** | unchanged | `send` — a real request |
175
- * | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
176
- *
177
- * ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
178
- * schedules a paid service to end is not a tie-break, it is a coin toss with the
179
- * owner's money.
180
- *
181
- * ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
182
- * conservative reading of a difference: if the two differ, `conflict`; if they
183
- * agree, `none`. That withholds rather than sends, which is safe HERE — unlike
184
- * `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
185
- *
186
- * @param {object} siteYml
187
- * @param {*} remoteServices - the status read's `services` rows, or undefined
188
- * @param {object|null} lastDeploy - the banked base
189
- * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
190
- */
191
- export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
192
- return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
193
- }
194
-
195
- /**
196
- * The same reconcile over any two-way request field.
74
+ * A two-way request field, reconciled against what the site has stored.
197
75
  *
198
- * ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
199
- * the same shape — the owner's ASK, pushed up, projected back on pull, and stored
200
- * on the other side where something else may move it.
76
+ * `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
77
+ * and stored on the other side where something else may move it.
201
78
  *
202
- * ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
203
- * reasoning that nearly left languages out: "nobody overwrites it, so there is no
204
- * hazard." Overwriting is not the only thing a base is for — without one, a value
205
- * that has always been in the file is indistinguishable from one the owner just
206
- * typed, so the CLI cannot tell an intentional change from the status quo. For
207
- * languages that difference is money: the count is priced, so a new language is a
208
- * charge, and saying so before sending requires knowing it is new.
79
+ * ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
80
+ * only thing a base is for — without one, a value that has always been in the file
81
+ * is indistinguishable from one the owner just typed, so the CLI cannot tell an
82
+ * intentional change from the status quo. For languages that difference is money:
83
+ * the count is priced, so a new language is a charge, and saying so before sending
84
+ * requires knowing it is new.
209
85
  *
210
86
  * @param {*} localValue - the file's declaration
211
87
  * @param {*} remoteValue - what the site has stored
212
88
  * @param {string|null} baseFingerprint - what we last agreed on
89
+ * @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
213
90
  */
214
91
  export function reconcile(localValue, remoteValue, baseFingerprint) {
215
92
  const local = fingerprintDeclaration(localValue)
@@ -217,11 +94,9 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
217
94
  const base = baseFingerprint || null
218
95
 
219
96
  if (local === remote) return { action: 'none', local, remote }
220
- // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
221
- // nothing stored either — an empty list is the store's nothing — there is nothing to do; with
222
- // something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
223
- // first publish of a site whose file is silent warned that its services "were changed
224
- // elsewhere, and site.yml changed too", listing both as nothing.
97
+ // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
98
+ // With nothing stored either there is nothing to do; with something stored, the file
99
+ // is merely behind.
225
100
  if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
226
101
  if (!base) return { action: 'conflict', local, remote }
227
102
 
@@ -231,3 +106,159 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
231
106
  if (localMoved && !remoteMoved) return { action: 'send', local, remote }
232
107
  return { action: 'conflict', local, remote }
233
108
  }
109
+
110
+ /**
111
+ * What a publish records in `deploy.yml` about the language selection: its
112
+ * fingerprint, on EVERY publish — it rides the content push, so what went live is
113
+ * what was agreed.
114
+ *
115
+ * ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
116
+ * selection itself is in `site.yml`.
117
+ *
118
+ * @param {object} siteYml
119
+ * @returns {{publishLanguagesRequest?: string}}
120
+ */
121
+ export function bankLanguages(siteYml) {
122
+ const langs = fingerprintDeclaration(siteYml?.publishLanguages)
123
+ return langs ? { publishLanguagesRequest: langs } : {}
124
+ }
125
+
126
+ /** One service, as an owner reads it: `on`, `off`, `on (grade: pro)`. */
127
+ function describeService(row) {
128
+ if (!row || typeof row !== 'object') return 'nothing set'
129
+ const state = row.enabled === false ? 'off' : 'on'
130
+ const settings =
131
+ row.config && typeof row.config === 'object' ? Object.entries(row.config) : []
132
+ if (!settings.length) return state
133
+ const shown = settings
134
+ .map(([k, v]) => `${k}: ${v !== null && typeof v === 'object' ? '…' : String(v)}`)
135
+ .join(', ')
136
+ return `${state} (${shown})`
137
+ }
138
+
139
+ const rowNamed = (rows, name) =>
140
+ Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
141
+
142
+ /**
143
+ * Decide what a push or publish sends of `site.yml::services` — and, once it has
144
+ * succeeded, what the project records.
145
+ *
146
+ * Per service the file names, three states are compared: the file, the site now
147
+ * (`status.services`), and the record of the last agreement (this backend's entry in
148
+ * `sync.json`). What the owner changed is sent; what the site changed is kept, and
149
+ * offered into `site.yml`; where both changed, the owner is asked. The list sent is the
150
+ * site's own, with only the changed asks applied (`mergeServiceRows`) — so every other
151
+ * service and setting the site holds is sent as it is stored.
152
+ *
153
+ * ⛔ An open decision — an offer declined, a conflict not resolved, or no terminal to
154
+ * ask at — is never sent, and the record keeps the earlier agreement for it, so the
155
+ * next run still sees the site's change rather than reading it as the file's.
156
+ *
157
+ * @param {object} p
158
+ * @param {object} p.client - the backend client (`origin`, `siteStatus`)
159
+ * @param {string} p.siteDir
160
+ * @param {object} p.siteYml - parsed; updated in place when the owner takes the site's services
161
+ * @param {object|null} [p.status] - the site's status, when the caller has just read it
162
+ * @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
163
+ * @param {boolean} [p.interactive] - whether the owner can be asked
164
+ * @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
165
+ * @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
166
+ * @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
167
+ * producer; `after` — call once the push succeeded (or had nothing to send)
168
+ */
169
+ export async function settleServices({
170
+ client,
171
+ siteDir,
172
+ siteYml,
173
+ status,
174
+ offline = false,
175
+ interactive = false,
176
+ confirm,
177
+ say
178
+ }) {
179
+ const nothing = { emit: {}, after: () => {} }
180
+ const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) })
181
+ if (!asks) return nothing
182
+
183
+ const state = readBackendState(siteDir, client.origin)
184
+ const record = Array.isArray(state.services) ? state.services : undefined
185
+ const siteUuid = state.site?.uuid || null
186
+ let read = status
187
+ if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
188
+ const stored = Array.isArray(read?.services) ? read.services : undefined
189
+
190
+ const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
191
+ if (decision.unreadable) {
192
+ say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
193
+ say.dim(' Run `uniweb pull` to take them, then push again.')
194
+ return nothing
195
+ }
196
+
197
+ const askFor = (name) => asks.find((a) => a.name === name)
198
+ const send = [...decision.send]
199
+ let offered = [...decision.adopt]
200
+ const open = []
201
+
202
+ // ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
203
+ // content they asked to push is a separate thing — and never a guess.
204
+ if (decision.conflict.length) {
205
+ say.warn(
206
+ record
207
+ ? "Your site's services and site.yml both changed since your last sync:"
208
+ : 'site.yml asks for services your site has set differently:'
209
+ )
210
+ for (const name of decision.conflict) {
211
+ say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
212
+ }
213
+ if (!interactive) {
214
+ say.dim(" Left as your site has them — run without --non-interactive to choose.")
215
+ open.push(...decision.conflict)
216
+ } else if (await confirm('Use the services in site.yml?', false)) {
217
+ send.push(...decision.conflict)
218
+ } else {
219
+ // Declining to send is not yet a decision to take the site's: offered below.
220
+ offered = [...offered, ...decision.conflict]
221
+ }
222
+ }
223
+
224
+ // The site moved and the file did not: the file is behind. Offered, never done — a
225
+ // push changes `site.yml` only when the owner says so.
226
+ if (offered.length) {
227
+ say.info("Your site's services changed since your last sync:")
228
+ for (const name of offered) {
229
+ say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
230
+ }
231
+ if (interactive && (await confirm('Update site.yml to match?', false))) {
232
+ const services = takeServices(siteYml.services, stored, offered)
233
+ writeSiteConfig(siteDir, { services })
234
+ if (services) siteYml.services = services
235
+ else delete siteYml.services
236
+ say.ok('site.yml updated.')
237
+ } else {
238
+ open.push(...offered)
239
+ }
240
+ }
241
+
242
+ if (send.length) {
243
+ say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
244
+ }
245
+
246
+ // The list to send: the site's rows with the changed asks applied. With the site
247
+ // unreadable and nothing changed, nothing is sent — the record may be stale, and
248
+ // sending it would be asking for what the site may have moved away from.
249
+ const base = stored ?? record ?? (siteUuid ? undefined : [])
250
+ if (!stored && !send.length) {
251
+ return {
252
+ emit: { declareServices: false },
253
+ after: () => {}
254
+ }
255
+ }
256
+ const rows = mergeServiceRows(base, asks.filter((a) => send.includes(a.name)))
257
+ return {
258
+ emit: { serviceRows: rows },
259
+ after: () => {
260
+ const next = recordAfter({ record, agreed: rows, open })
261
+ if (next) updateBackendState(siteDir, client.origin, { services: next })
262
+ }
263
+ }
264
+ }
@@ -55,10 +55,9 @@ import {
55
55
  } from '@uniweb/build/site'
56
56
  import { emitSyncPackages } from '@uniweb/build/uwx'
57
57
  import {
58
- decideDeclaration,
59
- fingerprintRequest,
58
+ bankLanguages,
60
59
  reconcile,
61
- reconcileRequest
60
+ settleServices
62
61
  } from '../backend/service-request.js'
63
62
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
64
63
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
@@ -235,19 +234,6 @@ export function unservedLanguages(asked, served) {
235
234
  return asked.filter((l) => !got.has(l))
236
235
  }
237
236
 
238
- function describeServices(rows) {
239
- if (!Array.isArray(rows) || rows.length === 0) return 'nothing'
240
- return rows
241
- .map((r) => {
242
- const name = typeof r?.name === 'string' ? r.name : '?'
243
- // A row that omits `enabled` is an ask, not a refusal — the backend's three
244
- // states. Only an explicit `false` reads as off.
245
- return r?.enabled === false ? `${name} (off)` : name
246
- })
247
- .sort()
248
- .join(', ')
249
- }
250
-
251
237
  async function persistLastDeploy(siteDir, opts) {
252
238
  if (opts.saveDeploys === false) return
253
239
  try {
@@ -392,9 +378,9 @@ export async function publish(args = []) {
392
378
  resolved = resolvePublishTarget(deployYml, client.origin, {
393
379
  defaultBackend: DEFAULT_BACKEND_ORIGIN
394
380
  })
395
- // The last request we are known to have sent TO THIS BACKEND, for the declaration
396
- // gate below. Read from the SAME deploy.yml load — one read, and the memo is the
397
- // only durable record of it (see backend/service-request.js for why not the cache).
381
+ // The language selection last sent TO THIS BACKEND, for its reconcile below. Read
382
+ // from the SAME deploy.yml load — one read, and the memo is the only durable record
383
+ // of it (see backend/service-request.js).
398
384
  priorRequest = deployYml?.deploys?.[resolved.targetName] || null
399
385
  } catch {
400
386
  // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
@@ -818,98 +804,30 @@ export async function publish(args = []) {
818
804
  const injectInfo = {
819
805
  ...(fnd.ref ? { foundation: fnd.ref } : {})
820
806
  }
821
- // ⛔ IS THE FILE ASKING FOR ANYTHING BY ITS `$services` / `$secrets` BLOCK?
822
- //
823
- // The blocks ride inside the site-content document, so without this gate every
824
- // push re-sends them — and the backend REPLACES what it is sent. A paragraph
825
- // edit would therefore overwrite whatever the stored request has become, which
826
- // in the consent workflow is a decision the owner made in the app. Under "the
827
- // file is a request", an unchanged block is not asking for anything.
828
- //
829
- // ⚠️ The residual window, stated because it is real and narrow: the base is
830
- // banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
831
- // the next publish is not seen — the pulled block reads as unchanged-from-nothing
832
- // and is declared. It closes when the status route carries the stored request
833
- // (backend is adding it) and we compare against theirs instead of our memory.
834
- //
835
- // ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
836
- // banked fingerprint says what WE last sent; the status read says what the site
837
- // actually has. Only the second one sees a change made in the app, which is where
838
- // the consent workflow's decisions happen — so this is what closes the window
839
- // between a `uniweb pull` and the next publish.
840
- //
841
- // ⚖️ Degrades to the banked comparison on any failure — an older backend, a
842
- // network blip, a site never pushed. That is the shipped behaviour and it is safe:
843
- // it withholds an unchanged block and sends a changed one; it merely cannot see
844
- // the app's side.
845
- // What this site is PROVISIONED with on the backend being published to.
846
- const provisioned = readBackendState(siteDir, client.origin)
847
- const boundUuid = provisioned.site?.uuid || null
848
- let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
849
- let adopted = null
850
- // Before the push, so a never-synced site has no uuid and simply skips this.
851
- const status =
852
- // This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
853
- // the remote reconcile below never ran and every publish declared blind.
854
- boundUuid
855
- ? await client.siteStatus(boundUuid)
856
- : null
857
- if (status && Array.isArray(status.services)) {
858
- const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
859
- if (r.action === 'none') {
860
- declaration = { declare: false, reason: 'in-sync' }
861
- } else if (r.action === 'send') {
862
- declaration = { declare: true, reason: 'changed' }
863
- } else if (r.action === 'adopt') {
864
- // The owner decided in the app and this file is simply behind. Nothing to
865
- // ask for, so nothing is sent — and the file can be brought in line, which
866
- // is offered rather than done, because site.yml is theirs.
867
- declaration = { declare: false, reason: 'adopt' }
868
- adopted = status.services
869
- } else {
870
- // ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
871
- // publish is a separate thing the owner asked for, and blocking it over a
872
- // services disagreement couples two unrelated intents. Not a guess either;
873
- // the request stays in their file, unsent, and they are told.
874
- declaration = { declare: false, reason: 'conflict' }
875
- adopted = status.services
876
- }
877
- }
807
+ // ⭐ WHAT THE SITE HAS — read before the push, for the two requests a publish
808
+ // carries: the services (`site.yml::services`) and the language selection. Only
809
+ // this read sees a decision the owner made in the app since this clone last synced.
810
+ // It reads this backend's site, from sync.json; a never-synced site has none.
811
+ const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
812
+ const status = boundUuid ? await client.siteStatus(boundUuid) : null
878
813
 
879
- // ⛔ EVERY STRING BELOW IS FOR A SITE OWNER, NOT FOR US.
880
- //
881
- // "request", "declaration", "send", "adopt", "reconcile" are how this file
882
- // MODELS the problem and they are the wrong words to say out loud: an author
883
- // does not think they are sending a request, they think they want their site to
884
- // have search. Say services, on and off, site.yml and your site. The internal
885
- // vocabulary stays in the code and the comments, where it earns its precision.
886
- //
887
- // ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
888
- //
889
- // `conflict` means the file and the site both moved since we last agreed, so
890
- // neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
891
- // is advice that CANNOT WORK: with no banked base the file has nothing to move
892
- // relative to, so editing it produces the same conflict forever. That shipped
893
- // for one commit. Asking is the only thing that resolves it.
894
- if (declaration.reason === 'conflict') {
895
- say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
896
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
897
- say.dim(` on your site: ${describeServices(adopted)}`)
898
- if (isNonInteractive(args)) {
899
- say.dim(' Left your site as it is — run without --non-interactive to choose.')
900
- } else if (await confirm('Use the services listed in site.yml?', false)) {
901
- declaration = { declare: true, reason: 'resolved-send' }
902
- adopted = null
903
- } else {
904
- // Declining to send is not yet a decision to take theirs, so this falls
905
- // through to the offer below and "neither, leave it alone" stays available.
906
- declaration = { declare: false, reason: 'adopt' }
907
- }
908
- }
814
+ // ⭐ THE SERVICES: what the owner changed in `site.yml::services` is sent, applied
815
+ // over the site's own list; what the site changed is kept and offered into the file;
816
+ // where both changed, the owner is asked (`settleServices`). An ask whose decision is
817
+ // still open is never sent over the decision the site holds.
818
+ const services = await settleServices({
819
+ client,
820
+ siteDir,
821
+ siteYml,
822
+ status,
823
+ interactive: !isNonInteractive(args),
824
+ confirm,
825
+ say
826
+ })
909
827
 
910
- // ⭐ THE SAME QUESTION FOR THE LANGUAGE SELECTION, and it is the one that costs.
828
+ // ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
911
829
  //
912
- // `publishLanguages` is a request like `$services`: pushed up, projected back on
830
+ // `publishLanguages` is a request like `services`: pushed up, projected back on
913
831
  // pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
914
832
  // today — which is why this was nearly skipped — but a base is not only for
915
833
  // detecting an overwrite. Without one, a selection that has always been in the
@@ -938,34 +856,6 @@ export async function publish(args = []) {
938
856
  }
939
857
  }
940
858
 
941
- if (declaration.reason === 'adopt' && adopted) {
942
- // ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
943
- // decision made in the app — but the same state follows a request of ours the
944
- // site refused, where nothing of theirs changed and ours simply did not take.
945
- // We cannot tell those apart here, so the wording claims neither.
946
- say.info('Your site has different services than site.yml lists.')
947
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
948
- say.dim(` on your site: ${describeServices(adopted)}`)
949
- // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
950
- // silently rewrites an authored file is the surprise this seam exists to
951
- // avoid. Default No, and declining costs nothing: the site is already
952
- // correct, only the file is behind, and the offer returns next publish.
953
- //
954
- // ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
955
- // been asked which they meant and said "not mine", taking the site's is the
956
- // other half of the same question, not a silent overwrite of an edit.
957
- if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
958
- const { writeSiteConfig } = await import('@uniweb/build/uwx')
959
- writeSiteConfig(siteDir, { $services: adopted })
960
- // Keep the in-memory copy in step, or the deploy.yml bank below records the
961
- // file as it WAS and the offer repeats forever.
962
- siteYml.$services = adopted
963
- say.ok('site.yml updated.')
964
- }
965
- } else if (!declaration.declare && declaration.reason !== 'adopt') {
966
- say.dim('Services unchanged.')
967
- }
968
-
969
859
  // publish rides the same gated push as `uniweb push`: if an app author has
970
860
  // edited since this clone last synced, the push is refused rather than
971
861
  // overwriting them, and nothing goes live. `--force` drops the precondition.
@@ -983,7 +873,8 @@ export async function publish(args = []) {
983
873
  backend: client.origin,
984
874
  // The keys this deployment's Sections take — see deploymentFields.
985
875
  ...fields,
986
- ...(declaration.declare ? {} : { declareServices: false }),
876
+ // The `services` Section as settled above — or withheld.
877
+ ...services.emit,
987
878
  // Placement identity for the folder — see writeFolderItemUuids.
988
879
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
989
880
  // Identity for the records' list items — see readRecordItemUuids.
@@ -1030,6 +921,9 @@ export async function publish(args = []) {
1030
921
  report
1031
922
  })
1032
923
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
924
+ // The push stored what it sent: the services agreed on are recorded now, whether or
925
+ // not the site then goes live.
926
+ services.after()
1033
927
  const siteUuid = pushResult.boundSiteUuid
1034
928
  if (!siteUuid) {
1035
929
  say.err('Push did not yield a site uuid — cannot go live.')
@@ -1127,24 +1021,9 @@ export async function publish(args = []) {
1127
1021
  lastDeploy: {
1128
1022
  at: new Date().toISOString(),
1129
1023
  host: 'uniweb',
1130
- // The request this publish is known to have sent — the base the declaration
1131
- // gate compares against next time. ⛔ A FINGERPRINT, never the block:
1132
- // deploy.yml is committed, `$secrets` carries secret material and a
1133
- // service's `config` is opaque, so recording either verbatim would write
1134
- // them into git. Absent when the file declares no block.
1135
- ...(declaration.declare
1136
- ? fingerprintRequest(siteYml, provisioned)
1137
- : {
1138
- // Nothing was sent, so the base is unchanged — carry it forward
1139
- // rather than dropping it, or the next publish would read "no record"
1140
- // and declare.
1141
- ...(priorRequest?.servicesRequest
1142
- ? { servicesRequest: priorRequest.servicesRequest }
1143
- : {}),
1144
- ...(priorRequest?.secretsRequest
1145
- ? { secretsRequest: priorRequest.secretsRequest }
1146
- : {})
1147
- }),
1024
+ // The language selection this publish sent — the base its reconcile compares
1025
+ // against next time. ⛔ A FINGERPRINT: the selection itself is in site.yml.
1026
+ ...bankLanguages(siteYml),
1148
1027
  // What was actually shipped. A version number can't answer that — two
1149
1028
  // publishes of "0.1.0" are not the same content — and after the fact the
1150
1029
  // working tree has moved on. `dirty` matters as much as the sha: it says the
@@ -74,7 +74,8 @@ import {
74
74
  syncedElsewhere,
75
75
  describeSyncedElsewhere
76
76
  } from '../utils/site-identity.js'
77
- import { confirm } from '../utils/interactive.js'
77
+ import { confirm, isNonInteractive } from '../utils/interactive.js'
78
+ import { settleServices } from '../backend/service-request.js'
78
79
  import { guardEmptyRecords } from '../utils/records-guard.js'
79
80
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
80
81
  import {
@@ -520,6 +521,20 @@ export async function push(args = [], deps = {}) {
520
521
  ref: siteYml?.foundation
521
522
  })
522
523
  }
524
+ // ⭐ THE SERVICES `site.yml` ASKS FOR — what the owner changed is sent over the site's
525
+ // own list; what the site changed is kept and offered into the file; where both
526
+ // changed, the owner is asked. Shared with `uniweb publish` (`settleServices`).
527
+ // Offline for `-o` and `--dry-run`: nothing is read, and nothing is recorded.
528
+ const offline = Boolean(output) || dryRun
529
+ const services = await settleServices({
530
+ client,
531
+ siteDir,
532
+ siteYml,
533
+ offline,
534
+ interactive: !offline && !isNonInteractive(args),
535
+ confirm,
536
+ say: { info, warn, dim: note, ok: success }
537
+ })
523
538
  const emitOptions = {
524
539
  backend: client.origin,
525
540
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -555,7 +570,9 @@ export async function push(args = [], deps = {}) {
555
570
  itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
556
571
  }),
557
572
  ...(assetRewrite ? { assetRewrite } : {}),
558
- ...(assetIds ? { assetIds } : {})
573
+ ...(assetIds ? { assetIds } : {}),
574
+ // The `services` Section as settled above — or withheld.
575
+ ...services.emit
559
576
  }
560
577
  let pkg
561
578
  try {
@@ -584,6 +601,8 @@ export async function push(args = [], deps = {}) {
584
601
 
585
602
  // Nothing changed since the last push — the backend is already up to date.
586
603
  if (totalEntities === 0) {
604
+ // The site already holds what this copy has, so what was settled is agreed.
605
+ if (!offline) services.after()
587
606
  success(
588
607
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
589
608
  )
@@ -644,6 +663,7 @@ export async function push(args = [], deps = {}) {
644
663
  }
645
664
  })
646
665
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
666
+ services.after()
647
667
  success(
648
668
  `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
649
669
  (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-06T02:16:00.292Z",
3
+ "generatedAt": "2026-10-06T19:21:14.306Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.10",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.76.1",
13
+ "version": "0.77.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.37.1",
38
+ "version": "0.37.2",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.17",
58
+ "version": "0.19.18",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -84,7 +84,7 @@
84
84
  ]
85
85
  },
86
86
  "@uniweb/runtime": {
87
- "version": "0.29.1",
87
+ "version": "0.29.2",
88
88
  "path": "framework/runtime",
89
89
  "deps": [
90
90
  "@uniweb/core",
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.20",
130
+ "version": "0.10.21",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",