uniweb 0.84.0 → 0.86.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 +6 -6
- package/partials/agents.md +126 -80
- package/src/backend/client.js +59 -6
- package/src/backend/foundation-bring-along.js +5 -3
- package/src/backend/service-request.js +91 -145
- package/src/backend/site-preview.js +1 -1
- package/src/backend/site-sync.js +161 -54
- package/src/commands/build.js +6 -2
- package/src/commands/clone.js +1 -1
- package/src/commands/deploy.js +3 -3
- package/src/commands/doctor.js +32 -36
- package/src/commands/forget.js +7 -7
- package/src/commands/publish.js +28 -30
- package/src/commands/pull.js +22 -11
- package/src/commands/push.js +19 -28
- package/src/commands/refresh.js +2 -1
- package/src/commands/register.js +6 -6
- package/src/commands/rename.js +6 -6
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +14 -5
- package/src/commands/status.js +1 -1
- package/src/framework-index.json +11 -11
- package/src/index.js +41 -11
- package/src/utils/config.js +13 -13
- package/src/utils/flag-guard.js +30 -16
- package/src/utils/site-identity.js +3 -3
- package/src/utils/yaml-edit.js +0 -115
package/src/backend/site-sync.js
CHANGED
|
@@ -34,15 +34,17 @@ import {
|
|
|
34
34
|
diffSiteUnits,
|
|
35
35
|
describeSiteDiff,
|
|
36
36
|
computeUnitHashes,
|
|
37
|
-
collectSiteUnits,
|
|
38
37
|
collectUnitUuids,
|
|
39
38
|
collectFolderItemUuids,
|
|
40
39
|
collectQueryUuids,
|
|
40
|
+
siteItemsByKey,
|
|
41
|
+
sameServiceRow,
|
|
42
|
+
heldServices,
|
|
41
43
|
readBackendState,
|
|
42
44
|
updateBackendMap,
|
|
43
45
|
clearBackendSections,
|
|
44
46
|
normalizeBackendOrigin,
|
|
45
|
-
|
|
47
|
+
writeSiteConfig,
|
|
46
48
|
harvestRecordItems,
|
|
47
49
|
storedRecordItems,
|
|
48
50
|
reprintRecordItems,
|
|
@@ -118,6 +120,35 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The items a `stale_base` refusal names by key — `stale_keys`, one
|
|
125
|
+
* `{ section, key }` per clashing item of a Section matched by a field of its own
|
|
126
|
+
* (`services` and `queries` by `name`) — as one line per Section.
|
|
127
|
+
*
|
|
128
|
+
* @param {*} staleKeys - the refusal's `stale_keys`
|
|
129
|
+
* @returns {string[]}
|
|
130
|
+
*/
|
|
131
|
+
export function describeStaleKeys(staleKeys) {
|
|
132
|
+
const bySection = new Map()
|
|
133
|
+
for (const entry of Array.isArray(staleKeys) ? staleKeys : []) {
|
|
134
|
+
const section = typeof entry?.section === 'string' && entry.section ? entry.section : null
|
|
135
|
+
if (!section) continue
|
|
136
|
+
const key = entry?.key
|
|
137
|
+
// A single-item Section (`settings`) is named by the Section alone, `key: {}`.
|
|
138
|
+
const single = key && typeof key === 'object' && !Object.keys(key).length
|
|
139
|
+
const name =
|
|
140
|
+
key && typeof key === 'object'
|
|
141
|
+
? typeof key.name === 'string' ? key.name : Object.values(key).filter((v) => typeof v === 'string').join(' ')
|
|
142
|
+
: typeof key === 'string' ? key : null
|
|
143
|
+
if (!single && !name) continue
|
|
144
|
+
if (!bySection.has(section)) bySection.set(section, [])
|
|
145
|
+
if (name) bySection.get(section).push(name)
|
|
146
|
+
}
|
|
147
|
+
return [...bySection].map(([section, names]) =>
|
|
148
|
+
`Changed on your site since your last pull — ${section}${names.length ? `: ${names.join(', ')}` : ''}`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
121
152
|
// A unit in the form it is compared in across the two representations: keys sorted,
|
|
122
153
|
// `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
|
|
123
154
|
const canonicalUnit = (v) =>
|
|
@@ -172,42 +203,65 @@ function writtenAsSent(sent, written) {
|
|
|
172
203
|
* changes it is `pkg != base, host != base` and refused. And while the backend holds
|
|
173
204
|
* units we don't, the entity token stays where it was, so the next push keeps them.
|
|
174
205
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
206
|
+
* ⭐ SINCE 2026-10-07 A HELD VERSION IS ALSO WHAT LETS A PUSH DELETE, so the rule covers
|
|
207
|
+
* every item, not only units. The site-content entity sends every version this copy
|
|
208
|
+
* holds, for items present and items the files dropped (`withBaseVersion`), and the
|
|
209
|
+
* backend deletes only the items a push holds a version for, while the site left them
|
|
210
|
+
* unchanged [Diego: "yes, apply it to pages too"; kb/framework/plans/services-exchange.md].
|
|
211
|
+
* A version banked for an item this copy never saw would therefore delete it. The map
|
|
212
|
+
* returned is the WHOLE set this copy now holds, in every Section but `secrets`
|
|
213
|
+
* (`siteItemsByKey`), and it replaces the one held before:
|
|
214
|
+
*
|
|
215
|
+
* - an item the backend holds as we sent it → its new version;
|
|
216
|
+
* - an item we sent that it kept for someone else → the version we held, for OUR content;
|
|
217
|
+
* - an item it holds that we did not send → the version we held, if we had seen it, and
|
|
218
|
+
* none if we had not (a unit of it is `foreign`);
|
|
219
|
+
* - an item we held that it no longer holds → none: the push deleted it, or the site did.
|
|
220
|
+
*
|
|
221
|
+
* ⛔ Until then only units were compared, and every other item's version was banked as
|
|
222
|
+
* the backend returned it — harmless only while none of them was ever sent.
|
|
223
|
+
*
|
|
224
|
+
* A service is compared by its switch and its whole `config` (`sameServiceRow`), every
|
|
225
|
+
* other item on the keys we sent (`writtenAsSent`).
|
|
226
|
+
*
|
|
227
|
+
* With either document missing there is nothing to compare, and with no versions
|
|
228
|
+
* returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
|
|
229
|
+
* held stay as they were. The contract is that `finalized[].document` is always there.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} p
|
|
232
|
+
* @param {object|null} p.sent - the site-content document this push sent
|
|
233
|
+
* @param {object|null} p.written - the backend's post-write copy of it
|
|
234
|
+
* @param {string|null} [p.version] - the entity's post-write version
|
|
235
|
+
* @param {Object<string,string>|null} [p.itemVersions] - the post-write version of every item
|
|
236
|
+
* @param {Object<string,string>} [p.held] - the versions this copy held before the push
|
|
179
237
|
* @returns {{ version: string|null, itemVersions: Object<string,string>|null,
|
|
180
|
-
* held: string[], kept: string[], foreign: string[] }} `
|
|
238
|
+
* held: string[], kept: string[], foreign: string[] }} `itemVersions`: the versions
|
|
239
|
+
* this copy now holds, or null to leave them as they were; `held`: units the backend
|
|
181
240
|
* holds as we sent them; `kept`: units it kept someone else's content for;
|
|
182
241
|
* `foreign`: units it holds that we did not send. All paths.
|
|
183
242
|
*/
|
|
184
|
-
export function heldTokens({ sent, written, version = null, itemVersions = null }) {
|
|
185
|
-
if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
243
|
+
export function heldTokens({ sent, written, version = null, itemVersions = null, held: before = {} }) {
|
|
244
|
+
if (!sent || !written) return { version, itemVersions: null, held: [], kept: [], foreign: [] }
|
|
245
|
+
const prior = before && typeof before === 'object' ? before : {}
|
|
246
|
+
const ours = siteItemsByKey(sent)
|
|
247
|
+
const theirs = siteItemsByKey(written)
|
|
248
|
+
const same = (key, a, b) => (key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b))
|
|
190
249
|
const held = []
|
|
191
250
|
const kept = []
|
|
192
251
|
const foreign = []
|
|
193
252
|
const items = {}
|
|
194
|
-
for (const [
|
|
195
|
-
if (!
|
|
196
|
-
|
|
253
|
+
for (const [key, item] of theirs) {
|
|
254
|
+
if (!item.uuid) continue
|
|
255
|
+
const unit = key.startsWith('unit:') ? key.slice('unit:'.length) : null
|
|
256
|
+
const mine = ours.get(key)
|
|
257
|
+
if (mine && same(key, mine.record, item.record)) {
|
|
258
|
+
if (unit) held.push(unit)
|
|
259
|
+
const token = itemVersions?.[item.uuid] ?? prior[item.uuid]
|
|
260
|
+
if (token) items[item.uuid] = token
|
|
197
261
|
continue
|
|
198
262
|
}
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
continue
|
|
202
|
-
}
|
|
203
|
-
held.push(path)
|
|
204
|
-
if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
|
|
205
|
-
}
|
|
206
|
-
// A token for an item that is not a unit (a `queries` declaration) is never sent
|
|
207
|
-
// back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
|
|
208
|
-
// banked as before: it can neither arm nor disarm anything.
|
|
209
|
-
for (const [uuid, token] of Object.entries(itemVersions || {})) {
|
|
210
|
-
if (!units.has(uuid)) items[uuid] = token
|
|
263
|
+
if (unit) (mine ? kept : foreign).push(unit)
|
|
264
|
+
if (prior[item.uuid]) items[item.uuid] = prior[item.uuid]
|
|
211
265
|
}
|
|
212
266
|
return {
|
|
213
267
|
version: foreign.length ? null : version,
|
|
@@ -218,6 +272,26 @@ export function heldTokens({ sent, written, version = null, itemVersions = null
|
|
|
218
272
|
}
|
|
219
273
|
}
|
|
220
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The versions a pull leaves this copy holding: one for every site-content item the
|
|
277
|
+
* pulled document carries, in every Section but `secrets` (`siteItemsByKey`) — the
|
|
278
|
+
* whole set, replacing what was held. A pull is how a copy sees the site, so an item
|
|
279
|
+
* the site no longer has is forgotten, and the next push does not send its version.
|
|
280
|
+
*
|
|
281
|
+
* @param {object|null} doc - the pulled site-content document
|
|
282
|
+
* @param {Object<string,string>|null} itemVersions - the pull manifest's `item_versions`
|
|
283
|
+
* @returns {Object<string,string>|null} null when there is nothing to bank (no document, or
|
|
284
|
+
* an older backend that sends no item versions)
|
|
285
|
+
*/
|
|
286
|
+
export function pulledItemVersions(doc, itemVersions) {
|
|
287
|
+
if (!doc || !itemVersions || typeof itemVersions !== 'object') return null
|
|
288
|
+
const out = {}
|
|
289
|
+
for (const { uuid } of siteItemsByKey(doc).values()) {
|
|
290
|
+
if (uuid && typeof itemVersions[uuid] === 'string') out[uuid] = itemVersions[uuid]
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
221
295
|
// The files a unit projects to, under the site's own roots — `site.yml::paths` can
|
|
222
296
|
// relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
|
|
223
297
|
// to three files.
|
|
@@ -378,7 +452,9 @@ export function keepModels(siteDir, backend, models) {
|
|
|
378
452
|
// ⭐ **Everything here is regenerable and losing it never produces a WRONG result** —
|
|
379
453
|
// only a slower transfer or a round trip. That is the membership test; anything
|
|
380
454
|
// failing it belongs in `sync.json` (which is where the uuid maps went on
|
|
381
|
-
// 2026-09-20 — see `readItemUuids` below).
|
|
455
|
+
// 2026-09-20 — see `readItemUuids` below). ⚠️ One exception since 2026-10-07, and it
|
|
456
|
+
// is said where it lives: the item versions are what lets a push delete, so without
|
|
457
|
+
// them a dropped page is kept until the next pull (`readItemBaseVersions`).
|
|
382
458
|
//
|
|
383
459
|
// ⚠️ It was `sync-cache.json` and its header called it "a pure wire-efficiency
|
|
384
460
|
// cache — NOT identity, the minted `$uuid` lives in the source files". That was
|
|
@@ -495,7 +571,7 @@ const readMap = (siteDir, backend, key) => {
|
|
|
495
571
|
* not change** — a partial site, published successfully, with nothing to
|
|
496
572
|
* indicate it.
|
|
497
573
|
*
|
|
498
|
-
* The guidance is `uniweb forget --
|
|
574
|
+
* The guidance is `uniweb forget --server <url>` now, which removes the uuid and the
|
|
499
575
|
* maps together, so following it no longer leads here.
|
|
500
576
|
*
|
|
501
577
|
* Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
|
|
@@ -642,7 +718,7 @@ export function dropSiteBoundValues(siteDir, backend) {
|
|
|
642
718
|
if (
|
|
643
719
|
y.preview !== undefined &&
|
|
644
720
|
!isAuthoredPreview(y.preview) &&
|
|
645
|
-
|
|
721
|
+
writeSiteConfig(siteDir, { preview: null }) === 'updated'
|
|
646
722
|
) {
|
|
647
723
|
dropped.push('preview')
|
|
648
724
|
}
|
|
@@ -771,20 +847,35 @@ export function readBaseVersions(siteDir, backend) {
|
|
|
771
847
|
}
|
|
772
848
|
|
|
773
849
|
/**
|
|
774
|
-
* Per-ITEM staleness tokens: `{ <
|
|
850
|
+
* Per-ITEM staleness tokens: `{ <item $uuid>: <opaque version> }` — one for every item
|
|
851
|
+
* of the site-content entity this copy has seen.
|
|
775
852
|
*
|
|
776
853
|
* The entity token gates the whole document, so a stale base on any one record
|
|
777
854
|
* refuses the entire push — which fires on the common case of two people editing
|
|
778
|
-
* different sections and teaches them to reach for `--force`. These gate per
|
|
855
|
+
* different sections and teaches them to reach for `--force`. These gate per item
|
|
779
856
|
* instead. Same contract as the entity token: opaque, cached, echoed, never parsed.
|
|
780
857
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
858
|
+
* ⭐ AND THEY ARE WHAT LETS A PUSH DELETE (2026-10-07): the backend deletes only the
|
|
859
|
+
* items a push holds a version for, so every one held is sent, present or dropped
|
|
860
|
+
* (`withBaseVersion`). Hence the set is REPLACED, never merged — by a pull
|
|
861
|
+
* (`pulledItemVersions`) and by a push of the site-content lane (`heldTokens`) — and
|
|
862
|
+
* holds only items this copy has seen. ⚠️ The one thing losing this cache now costs that
|
|
863
|
+
* is not a round trip: until the next pull, a page or section the files dropped is kept
|
|
864
|
+
* on the site rather than deleted, since there is no version to send for it.
|
|
865
|
+
* ⛔ *It was merged until then, across both lanes, for a push that sent only units.*
|
|
784
866
|
*/
|
|
785
867
|
export function readItemBaseVersions(siteDir, backend) {
|
|
786
868
|
return readMap(siteDir, backend, 'itemBaseVersions')
|
|
787
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* Replace the item versions this copy holds — a push's (`heldTokens`) or a pull's
|
|
872
|
+
* (`pulledItemVersions`) whole set. ⛔ Not a merge: an item that left the set must
|
|
873
|
+
* leave the map, or its version is sent again and asks the backend to delete it.
|
|
874
|
+
*/
|
|
875
|
+
export function writeItemBaseVersions(siteDir, backend, versions) {
|
|
876
|
+
if (!versions || typeof versions !== 'object') return
|
|
877
|
+
updateSyncCache(siteDir, backend, { itemBaseVersions: { ...versions } })
|
|
878
|
+
}
|
|
788
879
|
export function mergeItemBaseVersions(siteDir, backend, versions) {
|
|
789
880
|
if (!versions || !Object.keys(versions).length) return
|
|
790
881
|
// ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
|
|
@@ -1811,7 +1902,7 @@ export async function pushSyncPackages({
|
|
|
1811
1902
|
} catch (err) {
|
|
1812
1903
|
error(describeRequestError(err, client.origin))
|
|
1813
1904
|
if (!(err instanceof WorkspaceMismatchError))
|
|
1814
|
-
note('Is that the backend you meant? Switch with: uniweb login --
|
|
1905
|
+
note('Is that the backend you meant? Switch with: uniweb login --server <url>')
|
|
1815
1906
|
return null
|
|
1816
1907
|
}
|
|
1817
1908
|
if (!res.ok) {
|
|
@@ -1933,7 +2024,11 @@ export async function pushSyncPackages({
|
|
|
1933
2024
|
// for choosing between pulling and forcing.
|
|
1934
2025
|
const detail = await explainStale()
|
|
1935
2026
|
for (const line of detail) note(line)
|
|
1936
|
-
|
|
2027
|
+
// ⭐ Items no file path names — a service, a query — come named by their Section's
|
|
2028
|
+
// key (`stale_keys`, `{ section, key: { name } }`), so say them by name.
|
|
2029
|
+
const keyed = describeStaleKeys(problem.stale_keys)
|
|
2030
|
+
for (const line of keyed) note(line)
|
|
2031
|
+
if (!detail.length && !keyed.length) {
|
|
1937
2032
|
const stale = Array.isArray(problem.stale_entities)
|
|
1938
2033
|
? problem.stale_entities
|
|
1939
2034
|
: []
|
|
@@ -1972,7 +2067,7 @@ export async function pushSyncPackages({
|
|
|
1972
2067
|
error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
|
|
1973
2068
|
if (res.status === 401 || res.status === 403) {
|
|
1974
2069
|
note(
|
|
1975
|
-
"Credentials weren't accepted — log in again (`uniweb login --
|
|
2070
|
+
"Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
|
|
1976
2071
|
)
|
|
1977
2072
|
} else if (res.status === 404 && boundUuid) {
|
|
1978
2073
|
// The clone is bound to a site the backend does not have. There is no CLI
|
|
@@ -2002,10 +2097,10 @@ export async function pushSyncPackages({
|
|
|
2002
2097
|
'Two causes. Check the cheap one first: is this the backend the site lives on?'
|
|
2003
2098
|
)
|
|
2004
2099
|
note(
|
|
2005
|
-
` wrong backend → uniweb login --
|
|
2100
|
+
` wrong backend → uniweb login --server <the right one> (nothing is lost)`
|
|
2006
2101
|
)
|
|
2007
2102
|
note(
|
|
2008
|
-
` deleted there → uniweb forget --
|
|
2103
|
+
` deleted there → uniweb forget --server ${client.origin}, then push again: it creates a NEW site`
|
|
2009
2104
|
)
|
|
2010
2105
|
note('Deleting this folder removes only your local copy, either way.')
|
|
2011
2106
|
} else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
|
|
@@ -2079,9 +2174,11 @@ export async function pushSyncPackages({
|
|
|
2079
2174
|
// otherwise the next attempt would re-send lane 1 with a base the backend has
|
|
2080
2175
|
// already moved past, and refuse a push the user just made.
|
|
2081
2176
|
const newVersions = {}
|
|
2082
|
-
// Per-item tokens from the SAME response
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2177
|
+
// Per-item tokens from the SAME response — the site-content entity's, keyed by item
|
|
2178
|
+
// `$uuid`: the whole set this copy now holds (`heldTokens`), which replaces the one
|
|
2179
|
+
// it held. Null until that lane lands, which leaves the held set as it was.
|
|
2180
|
+
// ⛔ The records lane's are not kept: that lane is gated by its entity version, and
|
|
2181
|
+
// until 2026-10-07 they were merged into the same map, which was sent for nothing.
|
|
2085
2182
|
//
|
|
2086
2183
|
// ⛔ Both grains must be re-armed from the push, for one reason: a push writes,
|
|
2087
2184
|
// so every token this clone holds for a record it just changed is now stale. Read
|
|
@@ -2094,22 +2191,19 @@ export async function pushSyncPackages({
|
|
|
2094
2191
|
// exact shape one grain up (see delivery-lane.md "Both feed directions are
|
|
2095
2192
|
// load-bearing"); the item grain had the same hole until backend `d7e46335`
|
|
2096
2193
|
// started echoing `item_versions` here.
|
|
2097
|
-
|
|
2194
|
+
let heldItemVersions = null
|
|
2098
2195
|
const harvest = (finalized) => {
|
|
2099
2196
|
for (const f of finalized || []) {
|
|
2197
|
+
// NOT gated on `changed`: the backend pins "zero-write ⇒ version unmoved", so a
|
|
2198
|
+
// no-op resubmit hands back the value we already hold. (The site-content lane
|
|
2199
|
+
// filters first, `harvestSiteContent`.)
|
|
2100
2200
|
if (f.uuid && f.version) newVersions[f.uuid] = f.version
|
|
2101
|
-
// NOT gated on `changed` — same rule as the entity token: the backend pins
|
|
2102
|
-
// "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
|
|
2103
|
-
// already hold. (The site-content lane filters first, `harvestSiteContent`.)
|
|
2104
|
-
// An older backend omits the field entirely, which leaves the cached tokens
|
|
2105
|
-
// alone and degrades to the entity grain, exactly as before.
|
|
2106
|
-
if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
|
|
2107
2201
|
}
|
|
2108
2202
|
}
|
|
2109
2203
|
// Both grains land together, at every point the old code banked the entity one.
|
|
2110
2204
|
const mergeHarvested = () => {
|
|
2111
2205
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
2112
|
-
|
|
2206
|
+
if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
|
|
2113
2207
|
}
|
|
2114
2208
|
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
2115
2209
|
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
@@ -2124,9 +2218,11 @@ export async function pushSyncPackages({
|
|
|
2124
2218
|
sent: sentSiteDoc,
|
|
2125
2219
|
written: f.document,
|
|
2126
2220
|
version: f.version,
|
|
2127
|
-
itemVersions: f.itemVersions
|
|
2221
|
+
itemVersions: f.itemVersions,
|
|
2222
|
+
held: heldItemVersions ?? readItemBaseVersions(siteDir, client.origin)
|
|
2128
2223
|
})
|
|
2129
|
-
harvest([{ ...f, version: held.version
|
|
2224
|
+
harvest([{ ...f, version: held.version }])
|
|
2225
|
+
if (held.itemVersions) heldItemVersions = held.itemVersions
|
|
2130
2226
|
heldUnits.push(...held.held)
|
|
2131
2227
|
notHeld.kept.push(...held.kept)
|
|
2132
2228
|
notHeld.foreign.push(...held.foreign)
|
|
@@ -2153,6 +2249,17 @@ export async function pushSyncPackages({
|
|
|
2153
2249
|
if (siteFinalizedDoc) {
|
|
2154
2250
|
const recordIds = collectQueryUuids(siteFinalizedDoc)
|
|
2155
2251
|
if (Object.keys(recordIds).length) writeQueryUuids(siteDir, client.origin, recordIds)
|
|
2252
|
+
// ⭐ THE SERVICES THIS COPY NOW HOLDS, `{ name: $uuid }`: those the push stated and
|
|
2253
|
+
// those held before, as the site returned them (`heldServices`). A service added on
|
|
2254
|
+
// the site since this copy's last pull is left out — held, the next push would state
|
|
2255
|
+
// it off, switching off a service nobody here has seen. Replaced, not merged.
|
|
2256
|
+
updateBackendState(siteDir, client.origin, {
|
|
2257
|
+
services: heldServices({
|
|
2258
|
+
written: siteFinalizedDoc.services,
|
|
2259
|
+
sent: Array.isArray(sentSiteDoc?.services) ? sentSiteDoc.services : [],
|
|
2260
|
+
prior: readBackendState(siteDir, client.origin).services
|
|
2261
|
+
})
|
|
2262
|
+
})
|
|
2156
2263
|
}
|
|
2157
2264
|
// Re-base the page attribution: our emitted document and the backend's post-write
|
|
2158
2265
|
// copy of it are the two sides' new agreed state.
|
package/src/commands/build.js
CHANGED
|
@@ -138,10 +138,11 @@ function detectProjectType(projectDir) {
|
|
|
138
138
|
*/
|
|
139
139
|
function runCommand(command, args, cwd) {
|
|
140
140
|
return new Promise((resolve, reject) => {
|
|
141
|
+
// No shell: the command is node and a script path, which needs none — and a shell
|
|
142
|
+
// splits a path with a space in it. (Node warns DEP0190 on args passed with one.)
|
|
141
143
|
const proc = spawn(command, args, {
|
|
142
144
|
cwd,
|
|
143
|
-
stdio: 'inherit'
|
|
144
|
-
shell: true
|
|
145
|
+
stdio: 'inherit'
|
|
145
146
|
})
|
|
146
147
|
|
|
147
148
|
proc.on('close', (code) => {
|
|
@@ -246,6 +247,9 @@ async function buildFoundation(projectDir, options = {}) {
|
|
|
246
247
|
log('')
|
|
247
248
|
log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
|
|
248
249
|
|
|
250
|
+
// The next step is for someone who ran `uniweb build` — not for a push or publish
|
|
251
|
+
// that builds the foundation as one of its own steps (UNIWEB_BUILD_STEP).
|
|
252
|
+
if (process.env.UNIWEB_BUILD_STEP) return
|
|
249
253
|
log('')
|
|
250
254
|
log(`${colors.bright}Share with clients:${colors.reset}`)
|
|
251
255
|
log(
|
package/src/commands/clone.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* another workspace stops it.
|
|
45
45
|
*
|
|
46
46
|
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
47
|
-
*
|
|
47
|
+
* UNIWEB_SERVER > the local default (internal dev overrides;
|
|
48
48
|
* not the user-facing path — `uniweb login` determines the origin).
|
|
49
49
|
* Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
|
|
50
50
|
* from the backend commands 2026-09-21).
|
package/src/commands/deploy.js
CHANGED
|
@@ -195,8 +195,8 @@ export async function deploy(args = []) {
|
|
|
195
195
|
// uniweb target's `backend:` records where that target's publishes went; it routes
|
|
196
196
|
// nothing (publish files its record under the target naming its backend).
|
|
197
197
|
const goingTo = getRegistryApiBaseUrl()
|
|
198
|
-
const aimedBy = process.env.
|
|
199
|
-
? '
|
|
198
|
+
const aimedBy = process.env.UNIWEB_SERVER
|
|
199
|
+
? 'UNIWEB_SERVER'
|
|
200
200
|
: loggedInOrigin()
|
|
201
201
|
? 'the backend you are logged in to'
|
|
202
202
|
: 'the default backend — you are not logged in'
|
|
@@ -215,7 +215,7 @@ export async function deploy(args = []) {
|
|
|
215
215
|
say.err(
|
|
216
216
|
`Target '${resolved.targetName}' is on ${targetBackend}, but this would publish to ${goingTo} (${aimedBy}).`
|
|
217
217
|
)
|
|
218
|
-
say.dim(`To publish there, log in to it first: uniweb login --
|
|
218
|
+
say.dim(`To publish there, log in to it first: uniweb login --server ${targetBackend}`)
|
|
219
219
|
process.exit(1)
|
|
220
220
|
}
|
|
221
221
|
say.dim(
|
package/src/commands/doctor.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '@uniweb/build'
|
|
20
20
|
import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
|
|
21
21
|
import { listAdapters } from '@uniweb/build/hosts'
|
|
22
|
+
import { RUNTIME_KEYS } from '@uniweb/build/uwx'
|
|
22
23
|
import { getCliVersion } from '../versions.js'
|
|
23
24
|
import { readAgentsVersion } from '../utils/agents-stamp.js'
|
|
24
25
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
@@ -388,17 +389,12 @@ function nearestKnownKey(input, known) {
|
|
|
388
389
|
}
|
|
389
390
|
|
|
390
391
|
/**
|
|
391
|
-
* The options `tracking
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
* does not iterate a list. So there is no existing array to import and this
|
|
396
|
-
* duplicates nothing. It does mean the list can drift: the readers are
|
|
397
|
-
* `runtime/src/wire-foundation.js::wireTracker` (`consent`, `scripts`, `debug`)
|
|
398
|
-
* and `core/src/services.js::readEndpoint` (`endpoint`). Add a key there, add it
|
|
399
|
-
* here.
|
|
392
|
+
* The options `services.tracking` has a reader for — the build's list, which also
|
|
393
|
+
* decides what a push sends the host as its settings (`RUNTIME_KEYS`, `@uniweb/build`).
|
|
394
|
+
* ⛔ *Until 2026-10-06 doctor kept a copy of its own, which had drifted: it lacked
|
|
395
|
+
* `emit` and `flushIntervalMs`, which `wireTracker` reads.*
|
|
400
396
|
*/
|
|
401
|
-
const TRACKING_KEYS =
|
|
397
|
+
const TRACKING_KEYS = RUNTIME_KEYS.tracking
|
|
402
398
|
|
|
403
399
|
/**
|
|
404
400
|
* Spellings that read as an ATTEMPT to require consent but do not require it.
|
|
@@ -414,10 +410,10 @@ const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
|
|
|
414
410
|
const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
|
|
415
411
|
|
|
416
412
|
/**
|
|
417
|
-
* `tracking
|
|
413
|
+
* `services.tracking` — flag the keys and values that are carried and never acted on.
|
|
418
414
|
*
|
|
419
|
-
*
|
|
420
|
-
* nothing downstream rejects a mistake in it. Every error here therefore fails
|
|
415
|
+
* A key the site's tracking does not read goes to the host as a setting, and the
|
|
416
|
+
* rest is resolved at render, so nothing downstream rejects a mistake in it. Every error here therefore fails
|
|
421
417
|
* the same way: **silently, at a visitor's browser, as an absence** — which is
|
|
422
418
|
* also exactly what a site that configured nothing looks like. There is no
|
|
423
419
|
* symptom to notice and nothing to grep for.
|
|
@@ -425,8 +421,8 @@ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true',
|
|
|
425
421
|
* ⭐ That is the whole argument for checking it at `doctor` time: it is the only
|
|
426
422
|
* moment in the chain where a person who can fix it is looking at it.
|
|
427
423
|
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
424
|
+
* An address, `true` or `false` carries no options — there is nothing in it to be
|
|
425
|
+
* wrong.
|
|
430
426
|
*/
|
|
431
427
|
/**
|
|
432
428
|
* `uniweb doctor` — is `package.json` behind what the build derived?
|
|
@@ -806,8 +802,8 @@ export function checkUngatedServiceControls({ foundationName, folderName, srcDir
|
|
|
806
802
|
}
|
|
807
803
|
|
|
808
804
|
export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
809
|
-
const tracking = siteYml?.tracking
|
|
810
|
-
if (tracking === undefined || tracking === null) return
|
|
805
|
+
const tracking = siteYml?.services?.tracking
|
|
806
|
+
if (tracking === undefined || tracking === null || typeof tracking === 'boolean') return
|
|
811
807
|
if (typeof tracking === 'string') return
|
|
812
808
|
|
|
813
809
|
if (typeof tracking !== 'object' || Array.isArray(tracking)) {
|
|
@@ -816,9 +812,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
|
816
812
|
id,
|
|
817
813
|
type: 'warning',
|
|
818
814
|
site: siteName,
|
|
819
|
-
message: `site.yml: \`tracking
|
|
815
|
+
message: `site.yml: \`services.tracking\` should be true, false, an address, or a map of options`
|
|
820
816
|
})
|
|
821
|
-
warn(`[${id}] ${siteName}: \`tracking
|
|
817
|
+
warn(`[${id}] ${siteName}: \`services.tracking\` is neither true, false, an address nor a map of options.`)
|
|
822
818
|
return
|
|
823
819
|
}
|
|
824
820
|
|
|
@@ -830,10 +826,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
|
830
826
|
id,
|
|
831
827
|
type: 'warning',
|
|
832
828
|
site: siteName,
|
|
833
|
-
message: `site.yml: \`tracking
|
|
829
|
+
message: `site.yml: \`services.tracking\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
|
|
834
830
|
})
|
|
835
831
|
warn(
|
|
836
|
-
`[${id}] ${siteName}: \`tracking
|
|
832
|
+
`[${id}] ${siteName}: \`services.tracking\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
|
|
837
833
|
.map((k) => `'${k}'`)
|
|
838
834
|
.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
|
|
839
835
|
)
|
|
@@ -842,7 +838,7 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
|
842
838
|
if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
|
|
843
839
|
}
|
|
844
840
|
log(
|
|
845
|
-
` ${colors.dim}
|
|
841
|
+
` ${colors.dim}Not an option the site's tracking reads — sent to your host as a setting. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
|
|
846
842
|
)
|
|
847
843
|
}
|
|
848
844
|
|
|
@@ -869,9 +865,9 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
|
869
865
|
id,
|
|
870
866
|
type: 'warning',
|
|
871
867
|
site: siteName,
|
|
872
|
-
message: `site.yml: \`tracking.consent
|
|
868
|
+
message: `site.yml: \`services.tracking.consent\` is ${shown} — only the exact value \`required\` turns the gate on`
|
|
873
869
|
})
|
|
874
|
-
warn(`[${id}] ${siteName}: \`tracking.consent
|
|
870
|
+
warn(`[${id}] ${siteName}: \`services.tracking.consent\` is ${shown}; the gate is OFF.`)
|
|
875
871
|
log(
|
|
876
872
|
` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
|
|
877
873
|
)
|
|
@@ -894,10 +890,10 @@ export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
|
894
890
|
id,
|
|
895
891
|
type: 'warning',
|
|
896
892
|
site: siteName,
|
|
897
|
-
message: `site.yml: \`tracking.scripts
|
|
893
|
+
message: `site.yml: \`services.tracking.scripts\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
|
|
898
894
|
})
|
|
899
895
|
warn(
|
|
900
|
-
`[${id}] ${siteName}: ${bad.length} \`tracking.scripts
|
|
896
|
+
`[${id}] ${siteName}: ${bad.length} \`services.tracking.scripts\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
|
|
901
897
|
)
|
|
902
898
|
log(
|
|
903
899
|
` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
|
|
@@ -927,19 +923,19 @@ function editDistance(a, b) {
|
|
|
927
923
|
/**
|
|
928
924
|
* A site whose content declares a form needs somewhere to send it.
|
|
929
925
|
*
|
|
930
|
-
* Two things can supply that: `submit
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
* it right.
|
|
926
|
+
* Two things can supply that: `services.submit` in site.yml — asking the host for
|
|
927
|
+
* form handling, or naming the site's own — or the host at serve time. Doctor can
|
|
928
|
+
* only see the first — so it warns only when *nothing* could plausibly supply one:
|
|
929
|
+
* no entry, and no deploy target that would put a host in the picture. On a site
|
|
930
|
+
* bound to a host, an absent entry can be the correct configuration, and warning
|
|
931
|
+
* there would nag exactly the people who got it right.
|
|
936
932
|
*
|
|
937
933
|
* The consequence of being wrong in the other direction is what justifies the
|
|
938
|
-
* check at all: a form with no destination
|
|
939
|
-
*
|
|
934
|
+
* check at all: a form with no destination is not drawn, which is easy to ship
|
|
935
|
+
* without noticing.
|
|
940
936
|
*/
|
|
941
937
|
export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issues }) {
|
|
942
|
-
if (siteYml?.submit) return
|
|
938
|
+
if (siteYml?.services?.submit) return
|
|
943
939
|
|
|
944
940
|
const forms = findFormContent(sitePath, siteYml)
|
|
945
941
|
if (forms.length === 0) return
|
|
@@ -966,7 +962,7 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
|
|
|
966
962
|
for (const f of forms.slice(0, 5)) log(` • ${f}`)
|
|
967
963
|
if (n > 5) log(` ${colors.dim}…and ${n - 5} more${colors.reset}`)
|
|
968
964
|
log(
|
|
969
|
-
`
|
|
965
|
+
` Ask your host for form handling — ${colors.green}services: { submit: true }${colors.reset} — or name your own: ${colors.green}services: { submit: https://… }${colors.reset}.`
|
|
970
966
|
)
|
|
971
967
|
log(
|
|
972
968
|
` ${colors.dim}A host that handles submissions supplies one itself — this check is skipped once a deploy target is configured.${colors.reset}`
|
package/src/commands/forget.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* `uniweb forget` — remove what this project recorded about where it has synced and
|
|
3
3
|
* deployed. Local files only; nothing on any backend changes.
|
|
4
4
|
*
|
|
5
|
-
* uniweb forget --
|
|
5
|
+
* uniweb forget --server <url> one backend
|
|
6
6
|
* uniweb forget --all everything — for a COPY that is to become a new project
|
|
7
7
|
*
|
|
8
|
-
* ## `--
|
|
8
|
+
* ## `--server <url>`
|
|
9
9
|
*
|
|
10
10
|
* - that backend's section of `sync.json` — the site uuid, the record map, asset
|
|
11
11
|
* ids, provisioned services and the rest of what it minted;
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
* ## Why it exists
|
|
44
44
|
*
|
|
45
45
|
* Two jobs. A script pushes a template site to a short-lived dev server and then
|
|
46
|
-
* removes the traces of it without discarding the rest of the project (`--
|
|
46
|
+
* removes the traces of it without discarding the rest of the project (`--server`)
|
|
47
47
|
* — which is also why traceless publish was dropped: push and pull leave traces too.
|
|
48
48
|
* And someone duplicates a project to start a new site from it (`--all`).
|
|
49
49
|
*
|
|
50
50
|
* ⚠️ **No default target.** Forgetting a backend you still use means its next push
|
|
51
|
-
* creates a second site there, so the verb makes you name it — `--
|
|
51
|
+
* creates a second site there, so the verb makes you name it — `--server` is
|
|
52
52
|
* required even when the project has synced with only one.
|
|
53
53
|
*/
|
|
54
54
|
|
|
@@ -96,9 +96,9 @@ export async function forget(args = []) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const all = args.includes('--all')
|
|
99
|
-
const flag = readFlagValue(args, '--
|
|
99
|
+
const flag = readFlagValue(args, '--server')
|
|
100
100
|
if (all && flag) {
|
|
101
|
-
say.err('Use --
|
|
101
|
+
say.err('Use --server <url> or --all, not both.')
|
|
102
102
|
return { exitCode: 2 }
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -108,7 +108,7 @@ export async function forget(args = []) {
|
|
|
108
108
|
if (all) return forgetAll(siteDir)
|
|
109
109
|
|
|
110
110
|
if (!flag) {
|
|
111
|
-
say.err('Name what to forget: uniweb forget --
|
|
111
|
+
say.err('Name what to forget: uniweb forget --server <url>')
|
|
112
112
|
if (known.length) {
|
|
113
113
|
say.dim(`This project has synced with: ${known.join(', ')}`)
|
|
114
114
|
} else {
|