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 +7 -7
- package/partials/agents.md +28 -10
- package/src/backend/service-request.js +204 -173
- package/src/commands/publish.js +34 -155
- package/src/commands/push.js +22 -2
- package/src/framework-index.json +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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/
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
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.
|
|
52
|
+
"@uniweb/build": "^0.77.0",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.8",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.5.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
2016
|
-
`
|
|
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
|
|
2020
|
-
|
|
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
|
|
2023
|
-
|
|
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
|
|
2142
|
-
> it
|
|
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
|
|
3
|
-
*
|
|
2
|
+
* The requests a push or publish carries — what the owner asks for, and whether it
|
|
3
|
+
* is new.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Two of them, kept by two mechanisms:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
* is
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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:
|
|
57
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
199
|
-
*
|
|
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
|
-
* ⛔
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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.
|
|
221
|
-
// nothing stored either
|
|
222
|
-
//
|
|
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
|
+
}
|
package/src/commands/publish.js
CHANGED
|
@@ -55,10 +55,9 @@ import {
|
|
|
55
55
|
} from '@uniweb/build/site'
|
|
56
56
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
57
57
|
import {
|
|
58
|
-
|
|
59
|
-
fingerprintRequest,
|
|
58
|
+
bankLanguages,
|
|
60
59
|
reconcile,
|
|
61
|
-
|
|
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
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
|
828
|
+
// ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
|
|
911
829
|
//
|
|
912
|
-
// `publishLanguages` is a request like
|
|
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
|
-
|
|
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
|
|
1131
|
-
//
|
|
1132
|
-
|
|
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
|
package/src/commands/push.js
CHANGED
|
@@ -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(', ')}` : '')
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
130
|
+
"version": "0.10.21",
|
|
131
131
|
"path": "framework/unipress",
|
|
132
132
|
"deps": [
|
|
133
133
|
"@uniweb/build",
|