uniweb 0.47.0 → 0.47.2

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.47.0",
3
+ "version": "0.47.2",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.19.0",
44
+ "@uniweb/kit": "^0.18.0",
45
+ "@uniweb/core": "^0.24.2",
45
46
  "@uniweb/semantic-parser": "^1.4.0",
46
- "@uniweb/core": "^0.24.1",
47
- "@uniweb/kit": "^0.18.0"
47
+ "@uniweb/runtime": "^0.19.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "^1.4.0",
51
50
  "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/build": "^0.43.0"
51
+ "@uniweb/semantic-parser": "^1.4.0",
52
+ "@uniweb/build": "^0.43.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -458,6 +458,29 @@ Read the markdown out loud. If an author would understand what every line does,
458
458
 
459
459
  **Parameter naming matters.** Would an author understand it without reading code? `columns: 3` yes, `gridCols: 3` no. `variant: centered` yes, `renderMode: flex-center` no. `align: left` yes, `contentAlignment: flex-start` no.
460
460
 
461
+ ### Repeated values — `placeholders:`
462
+
463
+ A value that appears on many pages goes in `site.yml` once, not in a dozen markdown files:
464
+
465
+ ```yaml
466
+ placeholders:
467
+ product: Uniweb
468
+ vendor:
469
+ email: billing@acme.example
470
+ ```
471
+
472
+ Any page then references it by name — `Write to {vendor.email}.` Dot paths work, and a record's own field of the same name always wins, so `{title}` on an article page is that article's title regardless of what the site declares.
473
+
474
+ ⛔ **Resolution is a foundation capability, not a framework one.** `{…}` is resolved by the foundation's `handlers.content` hook — normally `createLoomHandlers` from `@uniweb/loom`. Under a foundation without one, `placeholders:` is inert and pages render the literal `{vendor.email}`, which reads as a typo. The build warns when a site declares the key and the foundation has no content handler.
475
+
476
+ ```js
477
+ // main.js — a foundation opts in
478
+ import { createLoomHandlers } from '@uniweb/loom'
479
+ export default { handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }) }
480
+ ```
481
+
482
+ Placeholders are the simplest case of a larger language: the same expressions filter, sort, count and format live data (`{COUNT OF publications WHERE refereed}`). Reach for one when a value repeats; reach for the rest when content is genuinely derived from records.
483
+
461
484
  ### Icons
462
485
 
463
486
  Image syntax with a library prefix — **two interchangeable spellings, the same everywhere** (markdown, and Kit's `<Icon name>`):
@@ -29,8 +29,24 @@
29
29
  * timeout and then reported "payment was not completed", which was false.
30
30
  */
31
31
 
32
- /** Reasons the CLI knows how to act on. An ALLOWLIST, never an inventory. */
33
- const ACTIONABLE_REASONS = new Set(['no_subscription'])
32
+ /**
33
+ * THE DOOR IS THE URL, NOT THE REASON — and this replaced a reason allowlist.
34
+ *
35
+ * The rule the allowlist served is right and survives: **never route to a purchase
36
+ * surface from the ABSENCE of a recognised token.** But the hazard it guarded was
37
+ * INFERRING a door from silence, and when the backend NAMES a URL there is no
38
+ * inference left — an explicit remedy is the affirmative signal the allowlist was
39
+ * standing in for.
40
+ *
41
+ * ⚠️ Keeping it had turned into the opposite failure. The reason set is open by
42
+ * design ("includes"), so every new one — `pending_request` today — fell through to
43
+ * `stop` and the owner was shown a sentence **with no link**, for a door the
44
+ * backend had handed us. A stale allowlist now fails by WITHHOLDING help.
45
+ *
46
+ * ⇒ What remains is a shape rule, not a vocabulary one: open exactly what you were
47
+ * handed, when it is an absolute http(s) URL, or say exactly what you were told.
48
+ */
49
+ const OPENABLE = /^https?:\/\//i
34
50
 
35
51
  /**
36
52
  * Read a `402` from the publish call and decide what the CLI does. Pure — no
@@ -93,21 +109,46 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
93
109
  ? problem.reason
94
110
  : null
95
111
 
96
- if (!reason || !ACTIONABLE_REASONS.has(reason)) {
97
- return { kind: 'stop', reason, message }
98
- }
112
+ // ONLY A `problem+json` BODY CARRIES A DOOR, and this gate is NOT the reason
113
+ // allowlist that used to sit here — it is the other, narrower protection that
114
+ // was tangled up with it.
115
+ //
116
+ // The backend's refusals wear `application/problem+json` and say so as a stable
117
+ // guarantee. A plain `application/json` 402 is some other shape from some other
118
+ // part of the wire — and one of those carries `status` as a STRING where a
119
+ // problem body carries the number, so the two are not distinguishable by their
120
+ // fields. Reading a URL out of a body that never promised this envelope is how
121
+ // a `settle` gets synthesised from something that is not a refusal at all.
122
+ if (!isProblem) return { kind: 'stop', reason, message }
123
+
124
+ // The door, if one was handed over. `remedy_url` is the current spelling — ONE
125
+ // key for every reason, which is what lets a reason we have never heard of still
126
+ // reach its remedy.
127
+ //
128
+ // ⚖️ `settlement.url` is the older shape and is still read. Not an alias we
129
+ // maintain: it is what a backend that has not moved yet still serves, and
130
+ // dropping the read would take away a door those deployments have today. New
131
+ // code never emits it and nothing here prefers it.
132
+ const legacy = problem.settlement
133
+ const candidate =
134
+ (typeof problem.remedy_url === 'string' && problem.remedy_url) ||
135
+ (legacy && typeof legacy.url === 'string' && legacy.url) ||
136
+ null
99
137
 
100
- // Actionable but only if the backend actually handed over somewhere to go.
101
- // A recognised reason with no settlement block is a backend that has not
102
- // built that half yet: still a stop, and still with its own sentence.
103
- const s = problem.settlement
104
- const url = s && typeof s.url === 'string' && s.url ? s.url : null
138
+ // ONLY AN ABSOLUTE http(s) URL IS OPENED, and this is a safety rule rather
139
+ // than a compatibility one. The value arrives over the network and is handed to
140
+ // the platform's URL opener; a `file:` or a `javascript:` URL is not a place a
141
+ // person goes. Refusing them costs a legitimate backend nothing.
142
+ const url = candidate && OPENABLE.test(candidate) ? candidate : null
105
143
  if (!url) return { kind: 'stop', reason, message }
106
144
 
107
145
  return {
108
146
  kind: 'settle',
109
147
  url,
110
- handle: s && typeof s.handle === 'string' && s.handle ? s.handle : null,
148
+ handle:
149
+ legacy && typeof legacy.handle === 'string' && legacy.handle
150
+ ? legacy.handle
151
+ : null,
111
152
  reason,
112
153
  message
113
154
  }
@@ -132,7 +173,7 @@ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
132
173
  // The backend's own sentence is the HEADLINE when there is one. A generic
133
174
  // lead would be wrong as often as right — "payment is required" does not
134
175
  // describe a declined card — and `detail` is written for this reader.
135
- say.err(verdict.message || 'This site cannot go live until it is paid for.')
176
+ say.err(verdict.message || 'This site cannot go live yet.')
136
177
 
137
178
  if (verdict.kind !== 'settle') {
138
179
  // The push completed before go-live, so the content is safely stored.
@@ -142,19 +183,22 @@ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
142
183
 
143
184
  const { isNonInteractive } = await import('../utils/interactive.js')
144
185
  if (isNonInteractive(args)) {
145
- say.dim(`Complete it in a browser, then re-run \`uniweb publish\`:`)
186
+ say.dim(`Finish this in a browser, then re-run \`uniweb publish\`:`)
146
187
  say.dim(` ${verdict.url}`)
147
188
  return { opened: false }
148
189
  }
149
190
 
150
191
  const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
151
- say.info('Opening your browser to complete it…')
192
+ // ⛔ Reason-agnostic wording. The backend's own `detail` above carries the
193
+ // specifics; a lead sentence naming payment would be wrong the moment a refusal
194
+ // is a quota or an unverified domain — and the reason set is open by design.
195
+ say.info('Opening your browser to finish this…')
152
196
  say.dim(` ${verdict.url}`)
153
197
  // VERBATIM. Nothing is appended — see the header.
154
198
  const opened = await openBrowser(verdict.url)
155
199
  if (!opened) {
156
200
  say.warn('Could not open a browser automatically — open the URL above.')
157
201
  }
158
- say.dim('Once payment is complete, re-run `uniweb publish`.')
202
+ say.dim('Once that is done, re-run `uniweb publish`.')
159
203
  return { opened }
160
204
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The services request — is the file ASKING for something, or just carrying an
3
+ * old answer?
4
+ *
5
+ * ## The defect this exists to close
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.
11
+ *
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.
18
+ *
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` carries secret material, and `$services[].config` is opaque and
42
+ * per-service — anything may be in it. `deploy.yml` is committed, so recording
43
+ * either verbatim would write them into git. Equality is all this gate needs;
44
+ * *what* differs is a question for the backend's own copy of the request, not for
45
+ * a mirror of our own.
46
+ *
47
+ * @module
48
+ */
49
+
50
+ import { createHash } from 'node:crypto'
51
+
52
+ /**
53
+ * A stable fingerprint of one declared block, or `null` when the key is absent.
54
+ *
55
+ * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: absent
56
+ * means *"I am not telling you about this"* and `[]` is an explicit clear. This
57
+ * function only has to preserve that distinction — the destructive difference
58
+ * between them is the backend's.
59
+ *
60
+ * Rows are ordered by their serialized form and object keys sorted, so reordering
61
+ * rows or keys in the file is not a change. It is not a request to move a line.
62
+ *
63
+ * @param {*} declared - the raw `site.yml::$services` / `$secrets` value
64
+ * @returns {string|null} 16 hex chars, or null when undeclared
65
+ */
66
+ export function fingerprintDeclaration(declared) {
67
+ if (declared === undefined || declared === null) return null
68
+ const canonical = Array.isArray(declared)
69
+ ? declared.map(stableString).sort()
70
+ : [stableString(declared)]
71
+ return createHash('sha256')
72
+ .update(JSON.stringify(canonical))
73
+ .digest('hex')
74
+ .slice(0, 16)
75
+ }
76
+
77
+ /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
78
+ function stableString(value) {
79
+ return JSON.stringify(value, (_k, v) =>
80
+ v && typeof v === 'object' && !Array.isArray(v)
81
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]))
82
+ : v
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Both blocks' fingerprints, in the shape `deploy.yml::lastDeploy.<target>` keeps
88
+ * them. Keys are omitted rather than set to null, so an undeclared block leaves no
89
+ * trace in the file.
90
+ *
91
+ * @param {object} siteYml
92
+ * @returns {{servicesRequest?: string, secretsRequest?: string}}
93
+ */
94
+ export function fingerprintRequest(siteYml) {
95
+ const out = {}
96
+ const services = fingerprintDeclaration(siteYml?.$services)
97
+ if (services) out.servicesRequest = services
98
+ const secrets = fingerprintDeclaration(siteYml?.$secrets)
99
+ if (secrets) out.secretsRequest = secrets
100
+ // ⭐ The language selection is a request too, and it is the one that moves a
101
+ // PRICE — the line is billed on how many languages go out. Banking it is what
102
+ // lets a later edit read as "the owner asked for another language" rather than
103
+ // as a value that was always there.
104
+ const langs = fingerprintDeclaration(siteYml?.publishLanguages)
105
+ if (langs) out.publishLanguagesRequest = langs
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Should this push DECLARE the request blocks?
111
+ *
112
+ * ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
113
+ * that decides it:
114
+ *
115
+ * 1. It is the behaviour every CLI has had, so nothing regresses.
116
+ * 2. A project may legitimately have no `deploy.yml` (never published, or
117
+ * `autoSave: off`), and that is not evidence about the request.
118
+ * 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
119
+ * failure directions, sending an unchanged block writes back what is usually
120
+ * already there, while withholding a changed one leaves an owner's edit with
121
+ * no effect and nothing said. The loud failure is the better one.
122
+ *
123
+ * @param {object} siteYml - the parsed site.yml
124
+ * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
125
+ * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
126
+ */
127
+ export function decideDeclaration(siteYml, lastDeploy) {
128
+ const now = fingerprintRequest(siteYml)
129
+ if (!now.servicesRequest && !now.secretsRequest) {
130
+ // Nothing in the file to send. The gate is moot; say so rather than
131
+ // reporting "unchanged", which would imply a comparison happened.
132
+ return { declare: true, reason: 'undeclared' }
133
+ }
134
+ if (!lastDeploy || typeof lastDeploy !== 'object') {
135
+ return { declare: true, reason: 'no-record' }
136
+ }
137
+ const same =
138
+ now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
139
+ now.secretsRequest === (lastDeploy.secretsRequest || undefined)
140
+ return same
141
+ ? { declare: false, reason: 'unchanged' }
142
+ : { declare: true, reason: 'changed' }
143
+ }
144
+
145
+ /**
146
+ * The four-way reconcile, once the backend's own copy of the request is in hand.
147
+ *
148
+ * ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
149
+ * compares the file to our MEMORY of what we last sent, which leaves a window: a
150
+ * request changed in the app between a `uniweb pull` and the next publish reads as
151
+ * unchanged-from-nothing. Comparing to the backend's own rows closes it, because
152
+ * the base stops being something we have to remember correctly.
153
+ *
154
+ * ⛔ It still needs the banked fingerprint. Local and remote differing says the two
155
+ * disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
156
+ * the difference between "the app decided, adopt it" and "you edited, send it".
157
+ *
158
+ * ## The four outcomes
159
+ *
160
+ * | local | remote | |
161
+ * |---|---|---|
162
+ * | unchanged | unchanged | `none` — nobody asked anything |
163
+ * | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
164
+ * | **edited** | unchanged | `send` — a real request |
165
+ * | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
166
+ *
167
+ * ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
168
+ * schedules a paid service to end is not a tie-break, it is a coin toss with the
169
+ * owner's money.
170
+ *
171
+ * ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
172
+ * conservative reading of a difference: if the two differ, `conflict`; if they
173
+ * agree, `none`. That withholds rather than sends, which is safe HERE — unlike
174
+ * `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
175
+ *
176
+ * @param {object} siteYml
177
+ * @param {*} remoteServices - the status read's `services` rows, or undefined
178
+ * @param {object|null} lastDeploy - the banked base
179
+ * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
180
+ */
181
+ export function reconcileRequest(siteYml, remoteServices, lastDeploy) {
182
+ return reconcile(siteYml?.$services, remoteServices, lastDeploy?.servicesRequest)
183
+ }
184
+
185
+ /**
186
+ * The same reconcile over any two-way request field.
187
+ *
188
+ * ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
189
+ * the same shape — the owner's ASK, pushed up, projected back on pull, and stored
190
+ * on the other side where something else may move it.
191
+ *
192
+ * ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
193
+ * reasoning that nearly left languages out: "nobody overwrites it, so there is no
194
+ * hazard." Overwriting is not the only thing a base is for — without one, a value
195
+ * that has always been in the file is indistinguishable from one the owner just
196
+ * typed, so the CLI cannot tell an intentional change from the status quo. For
197
+ * languages that difference is money: the count is priced, so a new language is a
198
+ * charge, and saying so before sending requires knowing it is new.
199
+ *
200
+ * @param {*} localValue - the file's declaration
201
+ * @param {*} remoteValue - what the site has stored
202
+ * @param {string|null} baseFingerprint - what we last agreed on
203
+ */
204
+ export function reconcile(localValue, remoteValue, baseFingerprint) {
205
+ const local = fingerprintDeclaration(localValue)
206
+ const remote = fingerprintDeclaration(remoteValue)
207
+ const base = baseFingerprint || null
208
+
209
+ if (local === remote) return { action: 'none', local, remote }
210
+ if (!base) return { action: 'conflict', local, remote }
211
+
212
+ const localMoved = local !== base
213
+ const remoteMoved = remote !== base
214
+ if (!localMoved && remoteMoved) return { action: 'adopt', local, remote }
215
+ if (localMoved && !remoteMoved) return { action: 'send', local, remote }
216
+ return { action: 'conflict', local, remote }
217
+ }
@@ -57,6 +57,12 @@ import {
57
57
  rewriteSchemalessDataAssets
58
58
  } from '@uniweb/build/site'
59
59
  import { emitSyncPackages } from '@uniweb/build/uwx'
60
+ import {
61
+ decideDeclaration,
62
+ fingerprintRequest,
63
+ reconcile,
64
+ reconcileRequest
65
+ } from '../backend/service-request.js'
60
66
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
61
67
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
62
68
 
@@ -172,6 +178,69 @@ function languagesFromSiteYml(siteYml) {
172
178
  }
173
179
 
174
180
  // Persist deploy.yml lastDeploy memory (skipped on --no-save / autoSave 'off').
181
+ /**
182
+ * A one-line, human-readable account of a service request, for a terminal.
183
+ *
184
+ * ⛔ Deliberately lossy — it names what is on and what is off, not a service's
185
+ * opaque `config`. The owner is being told WHICH decision differs so they can go
186
+ * look; reproducing a per-service config blob in a warning would bury that.
187
+ * "nothing" is a real answer and reads better than an empty string.
188
+ */
189
+ /**
190
+ * A language selection, for a terminal.
191
+ *
192
+ * ⛔ An ABSENT selection is not an empty one, and the words have to keep them
193
+ * apart: no `publishLanguages` key means every declared language is publishable,
194
+ * while `[]` means explicitly none. "all of them" and "none" are opposite answers
195
+ * and a bare empty string would read as either.
196
+ */
197
+ function describeLanguages(value) {
198
+ if (value === undefined || value === null) return 'all of them'
199
+ if (!Array.isArray(value) || value.length === 0) return 'none'
200
+ return value.join(', ')
201
+ }
202
+
203
+ /**
204
+ * Languages the site was asked to publish that it did not publish.
205
+ *
206
+ * ⭐ THIS IS NOT COMPUTABLE FROM `site.yml`. The served set is decided where the
207
+ * publish happens — a code naming no declared language is ignored rather than
208
+ * refused, and the site's own declared set is whatever its last push left there,
209
+ * which is not necessarily this file. So the only honest source is what the publish
210
+ * reported back, and until now the CLI wrote that into `deploy.yml` and never
211
+ * looked at it.
212
+ *
213
+ * ⛔ The failure it makes visible is the quiet one: an author believes their site
214
+ * is live in three languages and it is live in two. Nothing errors, the publish
215
+ * succeeds, and the missing locale is indistinguishable from one nobody asked for.
216
+ *
217
+ * Only the missing direction is reported. A site serving MORE than was asked is a
218
+ * different question that nobody has, and inventing a message for it would be
219
+ * machinery for a reason that does not exist.
220
+ *
221
+ * @param {string[]|null} asked - what this publish sent
222
+ * @param {*} served - `locales` from the publish response
223
+ * @returns {string[]} asked-for and not served, in the order asked
224
+ */
225
+ export function unservedLanguages(asked, served) {
226
+ if (!Array.isArray(asked) || !Array.isArray(served)) return []
227
+ const got = new Set(served)
228
+ return asked.filter((l) => !got.has(l))
229
+ }
230
+
231
+ function describeServices(rows) {
232
+ if (!Array.isArray(rows) || rows.length === 0) return 'nothing'
233
+ return rows
234
+ .map((r) => {
235
+ const name = typeof r?.name === 'string' ? r.name : '?'
236
+ // A row that omits `enabled` is an ask, not a refusal — the backend's three
237
+ // states. Only an explicit `false` reads as off.
238
+ return r?.enabled === false ? `${name} (off)` : name
239
+ })
240
+ .sort()
241
+ .join(', ')
242
+ }
243
+
175
244
  async function persistLastDeploy(siteDir, opts) {
176
245
  if (opts.autoSave === 'off') return
177
246
  try {
@@ -295,12 +364,20 @@ export async function publish(args = []) {
295
364
  // always targets Uniweb hosting; resolveTarget gives us the target name +
296
365
  // autoSave for the lastDeploy memo.
297
366
  let resolved
367
+ let priorRequest = null
298
368
  try {
299
369
  const deployYml = await loadDeployYml(siteDir)
300
370
  // No --target on publish — it always targets Uniweb hosting; resolveTarget
301
371
  // returns the uniweb default (fromFile:false) when there's no deploy.yml, so
302
372
  // persistLastDeploy scaffolds the file as the "where it's deployed" record.
303
373
  resolved = resolveTarget(deployYml, null)
374
+ // The last request we are known to have sent, for the declaration gate below.
375
+ // Read from the SAME deploy.yml load — one read, and the memo is the only
376
+ // durable record of it (see backend/service-request.js for why not the cache).
377
+ priorRequest =
378
+ deployYml?.lastDeploy?.[resolved?.targetName] ||
379
+ deployYml?.lastDeploy?.uniweb ||
380
+ null
304
381
  } catch {
305
382
  // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
306
383
  resolved = {
@@ -701,9 +778,155 @@ export async function publish(args = []) {
701
778
  const injectInfo = {
702
779
  ...(fnd.ref ? { foundation: fnd.ref } : {})
703
780
  }
781
+ // ⛔ IS THE FILE ASKING FOR ANYTHING BY ITS `$services` / `$secrets` BLOCK?
782
+ //
783
+ // The blocks ride inside the site-content document, so without this gate every
784
+ // push re-sends them — and the backend REPLACES what it is sent. A paragraph
785
+ // edit would therefore overwrite whatever the stored request has become, which
786
+ // in the consent workflow is a decision the owner made in the app. Under "the
787
+ // file is a request", an unchanged block is not asking for anything.
788
+ //
789
+ // ⚠️ The residual window, stated because it is real and narrow: the base is
790
+ // banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
791
+ // the next publish is not seen — the pulled block reads as unchanged-from-nothing
792
+ // and is declared. It closes when the status route carries the stored request
793
+ // (backend is adding it) and we compare against theirs instead of our memory.
794
+ //
795
+ // ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
796
+ // banked fingerprint says what WE last sent; the status read says what the site
797
+ // actually has. Only the second one sees a change made in the app, which is where
798
+ // the consent workflow's decisions happen — so this is what closes the window
799
+ // between a `uniweb pull` and the next publish.
800
+ //
801
+ // ⚖️ Degrades to the banked comparison on any failure — an older backend, a
802
+ // network blip, a site never pushed. That is the shipped behaviour and it is safe:
803
+ // it withholds an unchanged block and sends a changed one; it merely cannot see
804
+ // the app's side.
805
+ let declaration = decideDeclaration(siteYml, priorRequest)
806
+ let adopted = null
807
+ // Before the push, so a never-synced site has no uuid and simply skips this.
808
+ const status =
809
+ typeof siteYml.$uuid === 'string' && siteYml.$uuid
810
+ ? await client.siteStatus(siteYml.$uuid)
811
+ : null
812
+ if (status && Array.isArray(status.services)) {
813
+ const r = reconcileRequest(siteYml, status.services, priorRequest)
814
+ if (r.action === 'none') {
815
+ declaration = { declare: false, reason: 'in-sync' }
816
+ } else if (r.action === 'send') {
817
+ declaration = { declare: true, reason: 'changed' }
818
+ } else if (r.action === 'adopt') {
819
+ // The owner decided in the app and this file is simply behind. Nothing to
820
+ // ask for, so nothing is sent — and the file can be brought in line, which
821
+ // is offered rather than done, because site.yml is theirs.
822
+ declaration = { declare: false, reason: 'adopt' }
823
+ adopted = status.services
824
+ } else {
825
+ // ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
826
+ // publish is a separate thing the owner asked for, and blocking it over a
827
+ // services disagreement couples two unrelated intents. Not a guess either;
828
+ // the request stays in their file, unsent, and they are told.
829
+ declaration = { declare: false, reason: 'conflict' }
830
+ adopted = status.services
831
+ }
832
+ }
833
+
834
+ const { isNonInteractive, confirm } = await import('../utils/interactive.js')
835
+
836
+ // ⛔ EVERY STRING BELOW IS FOR A SITE OWNER, NOT FOR US.
837
+ //
838
+ // "request", "declaration", "send", "adopt", "reconcile" are how this file
839
+ // MODELS the problem and they are the wrong words to say out loud: an author
840
+ // does not think they are sending a request, they think they want their site to
841
+ // have search. Say services, on and off, site.yml and your site. The internal
842
+ // vocabulary stays in the code and the comments, where it earns its precision.
843
+ //
844
+ // ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
845
+ //
846
+ // `conflict` means the file and the site both moved since we last agreed, so
847
+ // neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
848
+ // is advice that CANNOT WORK: with no banked base the file has nothing to move
849
+ // relative to, so editing it produces the same conflict forever. That shipped
850
+ // for one commit. Asking is the only thing that resolves it.
851
+ if (declaration.reason === 'conflict') {
852
+ say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
853
+ say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
854
+ say.dim(` on your site: ${describeServices(adopted)}`)
855
+ if (isNonInteractive(args)) {
856
+ say.dim(' Left your site as it is — run without --non-interactive to choose.')
857
+ } else if (await confirm('Use the services listed in site.yml?', false)) {
858
+ declaration = { declare: true, reason: 'resolved-send' }
859
+ adopted = null
860
+ } else {
861
+ // Declining to send is not yet a decision to take theirs, so this falls
862
+ // through to the offer below and "neither, leave it alone" stays available.
863
+ declaration = { declare: false, reason: 'adopt' }
864
+ }
865
+ }
866
+
867
+ // ⭐ THE SAME QUESTION FOR THE LANGUAGE SELECTION, and it is the one that costs.
868
+ //
869
+ // `publishLanguages` is a request like `$services`: pushed up, projected back on
870
+ // pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
871
+ // today — which is why this was nearly skipped — but a base is not only for
872
+ // detecting an overwrite. Without one, a selection that has always been in the
873
+ // file cannot be told from one the owner just typed, and for languages that is a
874
+ // charge: the line is billed on how many go out, so adding one costs money and
875
+ // the owner should hear that from us before it is sent, not from a refusal after.
876
+ if (status && !isNonInteractive(args)) {
877
+ const langs = reconcile(
878
+ siteYml.publishLanguages,
879
+ status.publish_languages,
880
+ priorRequest?.publishLanguagesRequest
881
+ )
882
+ if (langs.action === 'send') {
883
+ const asked = siteYml.publishLanguages
884
+ say.info(
885
+ `You changed which languages this site publishes: ${describeLanguages(asked)}.`
886
+ )
887
+ // ⚖️ "may" — the count is what is priced, and only the backend prices it.
888
+ // Framework says a charge is possible and never how much: this package is
889
+ // public and holds no prices, and a number we invented would be wrong.
890
+ say.dim(' Adding a language may cost more. You will be asked to confirm if so.')
891
+ } else if (langs.action === 'adopt' || langs.action === 'conflict') {
892
+ say.info('This site publishes different languages than site.yml lists.')
893
+ say.dim(` in site.yml: ${describeLanguages(siteYml.publishLanguages)}`)
894
+ say.dim(` on your site: ${describeLanguages(status.publish_languages)}`)
895
+ }
896
+ }
897
+
898
+ if (declaration.reason === 'adopt' && adopted) {
899
+ // ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
900
+ // decision made in the app — but the same state follows a request of ours the
901
+ // site refused, where nothing of theirs changed and ours simply did not take.
902
+ // We cannot tell those apart here, so the wording claims neither.
903
+ say.info('Your site has different services than site.yml lists.')
904
+ say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
905
+ say.dim(` on your site: ${describeServices(adopted)}`)
906
+ // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
907
+ // silently rewrites an authored file is the surprise this seam exists to
908
+ // avoid. Default No, and declining costs nothing: the site is already
909
+ // correct, only the file is behind, and the offer returns next publish.
910
+ //
911
+ // ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
912
+ // been asked which they meant and said "not mine", taking the site's is the
913
+ // other half of the same question, not a silent overwrite of an edit.
914
+ if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
915
+ const { writeSiteConfig } = await import('@uniweb/build/uwx')
916
+ writeSiteConfig(siteDir, { $services: adopted })
917
+ // Keep the in-memory copy in step, or the deploy.yml bank below records the
918
+ // file as it WAS and the offer repeats forever.
919
+ siteYml.$services = adopted
920
+ say.ok('site.yml updated.')
921
+ }
922
+ } else if (!declaration.declare && declaration.reason !== 'adopt') {
923
+ say.dim('Services unchanged.')
924
+ }
925
+
704
926
  let pkg
705
927
  try {
706
928
  pkg = await emitSyncPackages(siteDir, {
929
+ ...(declaration.declare ? {} : { declareServices: false }),
707
930
  // Placement identity for the folder — see writeFolderItemUuids.
708
931
  folderItemUuids: readFolderItemUuids(siteDir),
709
932
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
@@ -818,6 +1041,24 @@ export async function publish(args = []) {
818
1041
  lastDeploy: {
819
1042
  at: new Date().toISOString(),
820
1043
  host: 'uniweb',
1044
+ // The request this publish is known to have sent — the base the declaration
1045
+ // gate compares against next time. ⛔ A FINGERPRINT, never the block:
1046
+ // deploy.yml is committed, `$secrets` carries secret material and a
1047
+ // service's `config` is opaque, so recording either verbatim would write
1048
+ // them into git. Absent when the file declares no block.
1049
+ ...(declaration.declare
1050
+ ? fingerprintRequest(siteYml)
1051
+ : {
1052
+ // Nothing was sent, so the base is unchanged — carry it forward
1053
+ // rather than dropping it, or the next publish would read "no record"
1054
+ // and declare.
1055
+ ...(priorRequest?.servicesRequest
1056
+ ? { servicesRequest: priorRequest.servicesRequest }
1057
+ : {}),
1058
+ ...(priorRequest?.secretsRequest
1059
+ ? { secretsRequest: priorRequest.secretsRequest }
1060
+ : {})
1061
+ }),
821
1062
  // What was actually shipped. A version number can't answer that — two
822
1063
  // publishes of "0.1.0" are not the same content — and after the fact the
823
1064
  // working tree has moved on. `dirty` matters as much as the sha: it says the
@@ -835,7 +1076,13 @@ export async function publish(args = []) {
835
1076
  // its foundation's floor and is resolved by whoever serves it. Recording a
836
1077
  // value would be a snapshot that silently goes stale, of a decision this
837
1078
  // command does not make.
838
- locales: Array.isArray(result.locales) ? result.locales : languages
1079
+ // WHAT WENT OUT, or nothing. This fell back to `languages` — what we
1080
+ // ASKED FOR — when the response carried no `locales`, which put two
1081
+ // different facts under one key with no way to tell them apart: a reader
1082
+ // could not distinguish "these were served" from "we asked for these and
1083
+ // were never told". `deploy.yml` records what a publish DID, and when we do
1084
+ // not know what it did, the honest record is silence.
1085
+ ...(Array.isArray(result.locales) ? { locales: result.locales } : {})
839
1086
  }
840
1087
  })
841
1088
 
@@ -843,6 +1090,23 @@ export async function publish(args = []) {
843
1090
  say.ok(
844
1091
  `Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`
845
1092
  )
1093
+
1094
+ // ⛔ SAY IT WHEN A LANGUAGE ASKED FOR DID NOT GO OUT.
1095
+ //
1096
+ // The publish succeeded, so nothing above this is wrong — and a site live in two
1097
+ // of the three languages its author listed looks exactly like a site live in the
1098
+ // two they wanted. That is the failure with no owner: the file and reality
1099
+ // disagree and the terminal is green.
1100
+ //
1101
+ // ⚖️ A warning, not a failure. The publish DID happen and the content IS live, so
1102
+ // exiting non-zero would tell a script the deploy failed when it did not.
1103
+ const unserved = unservedLanguages(languages, result.locales)
1104
+ if (unserved.length > 0) {
1105
+ say.warn(
1106
+ `Your site went live in ${(result.locales || []).join(', ')} — but not ${unserved.join(', ')}.`
1107
+ )
1108
+ say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
1109
+ }
846
1110
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
847
1111
  if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
848
1112
  return { exitCode: 0 }
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-07T05:09:47.913Z",
3
+ "generatedAt": "2026-09-09T00:44:10.215Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.2.11",
6
+ "version": "0.2.12",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.43.0",
13
+ "version": "0.43.1",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.24.1",
37
+ "version": "0.24.2",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -64,7 +64,7 @@
64
64
  ]
65
65
  },
66
66
  "@uniweb/loom": {
67
- "version": "0.2.4",
67
+ "version": "0.2.5",
68
68
  "path": "framework/loom",
69
69
  "deps": []
70
70
  },
@@ -82,7 +82,7 @@
82
82
  ]
83
83
  },
84
84
  "@uniweb/runtime": {
85
- "version": "0.19.0",
85
+ "version": "0.19.3",
86
86
  "path": "framework/runtime",
87
87
  "deps": [
88
88
  "@uniweb/core",
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/templates": {
113
- "version": "0.12.1",
113
+ "version": "0.12.2",
114
114
  "path": "framework/templates",
115
115
  "deps": []
116
116
  },
@@ -120,7 +120,7 @@
120
120
  "deps": []
121
121
  },
122
122
  "@uniweb/unipress": {
123
- "version": "0.9.10",
123
+ "version": "0.9.11",
124
124
  "path": "framework/unipress",
125
125
  "deps": [
126
126
  "@uniweb/build",
@@ -339,15 +339,35 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
339
339
  // Exported for the publish payment refusal, which opens the backend's
340
340
  // settlement URL VERBATIM and needs no loopback (backend/payment-handoff.js).
341
341
  export async function openBrowser(url) {
342
+ // ⛔ THE URL ARRIVES OVER THE NETWORK — a backend hands it to us and we open it.
343
+ //
344
+ // This built a SHELL STRING (`open "${url}"`) and interpolated that value into
345
+ // it. A `"` in the URL closes the quoted argument and everything after it is
346
+ // shell: one crafted or corrupted response, and the rest runs as the user. It
347
+ // was on the login path before it was on the publish path.
348
+ //
349
+ // ⇒ Two changes, and neither is about payment:
350
+ // 1. `execFile` with the URL as an ARGUMENT — no shell, so no quoting to get
351
+ // wrong and nothing to escape.
352
+ // 2. Only `http:` / `https:` are opened. `file:`, `javascript:` and the rest
353
+ // are not places a person goes, and refusing them costs nothing real.
354
+ // (The caller checks too; a safety rule that only holds at one call site
355
+ // is one refactor from being gone.)
356
+ if (!/^https?:\/\//i.test(String(url || ''))) return false
342
357
  try {
343
- const { exec } = await import('node:child_process')
344
- const cmd =
358
+ const { execFile } = await import('node:child_process')
359
+ // `start` is a cmd.exe builtin rather than an executable, so Windows keeps a
360
+ // shell — but through `cmd /c` with the URL as its own argv entry, which is
361
+ // what removes the interpolation. The empty string is `start`'s title slot.
362
+ const [cmd, args] =
345
363
  process.platform === 'darwin'
346
- ? `open "${url}"`
364
+ ? ['open', [url]]
347
365
  : process.platform === 'win32'
348
- ? `start "" "${url}"`
349
- : `xdg-open "${url}"`
350
- return await new Promise((resolve) => exec(cmd, (err) => resolve(!err)))
366
+ ? ['cmd', ['/c', 'start', '', url]]
367
+ : ['xdg-open', [url]]
368
+ return await new Promise((resolve) =>
369
+ execFile(cmd, args, (err) => resolve(!err))
370
+ )
351
371
  } catch {
352
372
  return false
353
373
  }