uniweb 0.82.2 → 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.
@@ -1,68 +1,51 @@
1
1
  /**
2
- * The services request — is the file ASKING for something, or just carrying an
3
- * old answer?
2
+ * The requests a push or publish carries — what the owner asks for, and whether it
3
+ * is new.
4
4
  *
5
- * ## The defect this exists to close
5
+ * Two of them, kept by two mechanisms:
6
6
  *
7
- * `$services` / `$secrets` are Sections of the site-content document, so they ride
8
- * inside **every** push — the block is emitted whenever the key exists in
9
- * `site.yml`. Editing one paragraph on one page therefore re-sends the whole
10
- * request block.
7
+ * - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
8
+ * 2026-10-06]*. Sent as the site's own list with the owner's CHANGED asks applied,
9
+ * decided per service by comparing the file, the site now and the record of the
10
+ * last agreement in `sync.json` (`settleServices`; spec:
11
+ * kb/framework/reference/site-services-request.md).
12
+ * - **The language selection** — `site.yml::publishLanguages` — told apart from the
13
+ * status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
11
14
  *
12
- * ⛔ And the backend REPLACES what it is sent: a row anchors by its natural key and
13
- * is updated in place (`SectionScope::DeclaredOnly`; backend's
14
- * `uuidless_records_anchor_by_natural_key_and_keep_the_stored_uuid`, written
15
- * against this emitter's shape). So a re-send is not a harmless echo — it
16
- * **overwrites the stored request**, including a decision the owner made in the
17
- * app, which is where the consent workflow's publish happens.
15
+ * ⭐ The model both follow — *"the services in `site.yml` are a request, never a
16
+ * tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file. An
17
+ * unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
18
+ * re-send would overwrite a decision the owner made in the app since.
18
19
  *
19
- * ⭐ Under the model the file follows — *"the services in `site.yml` are a request,
20
- * never a tracking of what is running"* [Diego, 2026-09-05] — **re-sending an
21
- * unchanged block is making a request nobody made.** You ask by CHANGING the file.
22
- *
23
- * ⚠️ It was mostly inert until 2026-09-08: `enabled` was honoured for `api` alone,
24
- * so a stale re-send of the other names overwrote rows nothing read. That stopped
25
- * being true the same day, and `api` was never inert — an `enabled: false` on it
26
- * with a live plan schedules a paid service to end.
27
- *
28
- * ## What "changed" is measured against
29
- *
30
- * The last block we are known to have sent, recorded in **`deploy.yml`** — a
31
- * COMMITTED project file that travels with a clone.
32
- *
33
- * ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
34
- * wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
35
- * has no base at all — and this workflow is multi-machine by construction (consent
36
- * in a browser, publish from the app). A base that vanishes turns this gate into
37
- * either the original defect or a silently dropped request.
38
- *
39
- * ## ⛔ A HASH, never the block
40
- *
41
- * `$secrets` names every secret the site has (values are the `#ref` marker, never
42
- * secret material — the old wording here said otherwise), and `$services[].config` is opaque and
43
- * per-service — anything may be in it. `deploy.yml` is committed, so recording
44
- * either verbatim would write them into git. Equality is all this gate needs;
45
- * *what* differs is a question for the backend's own copy of the request, not for
46
- * a mirror of our own.
20
+ * ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
21
+ * nobody edits, so a CLI user could ask for nothing; and this module compared them by
22
+ * a fingerprint in `deploy.yml`, which could not tell who moved.*
47
23
  *
48
24
  * @module
49
25
  */
50
26
 
51
27
  import { createHash } from 'node:crypto'
28
+ import {
29
+ readServicesRequest,
30
+ takeServices,
31
+ mergeServiceRows,
32
+ reconcileServices,
33
+ recordAfter,
34
+ readBackendState,
35
+ updateBackendState,
36
+ writeSiteConfig
37
+ } from '@uniweb/build/uwx'
52
38
 
53
39
  /**
54
- * A stable fingerprint of one declared block, or `null` when the key is absent.
40
+ * A stable fingerprint of one declared value, or `null` when the key is absent.
55
41
  *
56
- * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: absent
57
- * means *"I am not telling you about this"* and `[]` is an explicit clear. This
58
- * function only has to preserve that distinction — the destructive difference
59
- * between them is the backend's.
42
+ * ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
43
+ * language selection, no key means "every declared language" and `[]` means none.
60
44
  *
61
- * Rows are ordered by their serialized form and object keys sorted, so reordering
62
- * rows or keys in the file is not a change. It is not a request to move a line.
45
+ * List entries are ordered by their serialized form and object keys sorted, so
46
+ * reordering them in the file is not a change.
63
47
  *
64
- * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
- * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
48
+ * @param {*} declared
66
49
  * @returns {string|null} 16 hex chars, or null when undeclared
67
50
  */
68
51
  export function fingerprintDeclaration(declared) {
@@ -70,13 +53,10 @@ export function fingerprintDeclaration(declared) {
70
53
  const canonical = Array.isArray(declared)
71
54
  ? declared.map(stableString).sort()
72
55
  : [stableString(declared)]
73
- return createHash('sha256')
74
- .update(JSON.stringify(canonical))
75
- .digest('hex')
76
- .slice(0, 16)
56
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
77
57
  }
78
58
 
79
- /** Absent, or an empty list — a stored request that asks for nothing. */
59
+ /** Absent, or an empty list — a stored value that asks for nothing. */
80
60
  function isNothing(value) {
81
61
  return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
82
62
  }
@@ -91,125 +71,22 @@ function stableString(value) {
91
71
  }
92
72
 
93
73
  /**
94
- * Both blocks' fingerprints, in the shape `deploy.yml::lastDeploy.<target>` keeps
95
- * them. Keys are omitted rather than set to null, so an undeclared block leaves no
96
- * trace in the file.
97
- *
98
- * @param {object} siteYml
99
- * @returns {{servicesRequest?: string, secretsRequest?: string}}
100
- */
101
- export function fingerprintRequest(siteYml, provisioned = {}) {
102
- const out = {}
103
- // ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
104
- // backend being published to (they were `site.yml::$services` / `$secrets` until
105
- // 2026-09-20). The language selection below is still authored, so still site.yml.
106
- const services = fingerprintDeclaration(provisioned?.services)
107
- if (services) out.servicesRequest = services
108
- const secrets = fingerprintDeclaration(provisioned?.secrets)
109
- if (secrets) out.secretsRequest = secrets
110
- // ⭐ The language selection is a request too, and it is the one that moves a
111
- // PRICE — the line is billed on how many languages go out. Banking it is what
112
- // lets a later edit read as "the owner asked for another language" rather than
113
- // as a value that was always there.
114
- const langs = fingerprintDeclaration(siteYml?.publishLanguages)
115
- if (langs) out.publishLanguagesRequest = langs
116
- return out
117
- }
118
-
119
- /**
120
- * Should this push DECLARE the request blocks?
121
- *
122
- * ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
123
- * that decides it:
124
- *
125
- * 1. It is the behaviour every CLI has had, so nothing regresses.
126
- * 2. A project may legitimately have no `deploy.yml` (never published, or
127
- * `autoSave: off`), and that is not evidence about the request.
128
- * 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
129
- * failure directions, sending an unchanged block writes back what is usually
130
- * already there, while withholding a changed one leaves an owner's edit with
131
- * no effect and nothing said. The loud failure is the better one.
132
- *
133
- * @param {object} siteYml - the parsed site.yml
134
- * @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
135
- * @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
136
- */
137
- export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
138
- const now = fingerprintRequest(siteYml, provisioned)
139
- if (!now.servicesRequest && !now.secretsRequest) {
140
- // Nothing in the file to send. The gate is moot; say so rather than
141
- // reporting "unchanged", which would imply a comparison happened.
142
- return { declare: true, reason: 'undeclared' }
143
- }
144
- if (!lastDeploy || typeof lastDeploy !== 'object') {
145
- return { declare: true, reason: 'no-record' }
146
- }
147
- const same =
148
- now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
149
- now.secretsRequest === (lastDeploy.secretsRequest || undefined)
150
- return same
151
- ? { declare: false, reason: 'unchanged' }
152
- : { declare: true, reason: 'changed' }
153
- }
154
-
155
- /**
156
- * The four-way reconcile, once the backend's own copy of the request is in hand.
157
- *
158
- * ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
159
- * compares the file to our MEMORY of what we last sent, which leaves a window: a
160
- * request changed in the app between a `uniweb pull` and the next publish reads as
161
- * unchanged-from-nothing. Comparing to the backend's own rows closes it, because
162
- * the base stops being something we have to remember correctly.
163
- *
164
- * ⛔ It still needs the banked fingerprint. Local and remote differing says the two
165
- * disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
166
- * the difference between "the app decided, adopt it" and "you edited, send it".
167
- *
168
- * ## The four outcomes
169
- *
170
- * | local | remote | |
171
- * |---|---|---|
172
- * | unchanged | unchanged | `none` — nobody asked anything |
173
- * | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
174
- * | **edited** | unchanged | `send` — a real request |
175
- * | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
176
- *
177
- * ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
178
- * schedules a paid service to end is not a tie-break, it is a coin toss with the
179
- * owner's money.
180
- *
181
- * ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
182
- * conservative reading of a difference: if the two differ, `conflict`; if they
183
- * agree, `none`. That withholds rather than sends, which is safe HERE — unlike
184
- * `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
185
- *
186
- * @param {object} siteYml
187
- * @param {*} remoteServices - the status read's `services` rows, or undefined
188
- * @param {object|null} lastDeploy - the banked base
189
- * @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
190
- */
191
- export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
192
- return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
193
- }
194
-
195
- /**
196
- * The same reconcile over any two-way request field.
74
+ * A two-way request field, reconciled against what the site has stored.
197
75
  *
198
- * ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
199
- * the same shape — the owner's ASK, pushed up, projected back on pull, and stored
200
- * on the other side where something else may move it.
76
+ * `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
77
+ * and stored on the other side where something else may move it.
201
78
  *
202
- * ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
203
- * reasoning that nearly left languages out: "nobody overwrites it, so there is no
204
- * hazard." Overwriting is not the only thing a base is for — without one, a value
205
- * that has always been in the file is indistinguishable from one the owner just
206
- * typed, so the CLI cannot tell an intentional change from the status quo. For
207
- * languages that difference is money: the count is priced, so a new language is a
208
- * charge, and saying so before sending requires knowing it is new.
79
+ * ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
80
+ * only thing a base is for — without one, a value that has always been in the file
81
+ * is indistinguishable from one the owner just typed, so the CLI cannot tell an
82
+ * intentional change from the status quo. For languages that difference is money:
83
+ * the count is priced, so a new language is a charge, and saying so before sending
84
+ * requires knowing it is new.
209
85
  *
210
86
  * @param {*} localValue - the file's declaration
211
87
  * @param {*} remoteValue - what the site has stored
212
88
  * @param {string|null} baseFingerprint - what we last agreed on
89
+ * @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
213
90
  */
214
91
  export function reconcile(localValue, remoteValue, baseFingerprint) {
215
92
  const local = fingerprintDeclaration(localValue)
@@ -217,11 +94,9 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
217
94
  const base = baseFingerprint || null
218
95
 
219
96
  if (local === remote) return { action: 'none', local, remote }
220
- // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
221
- // nothing stored either — an empty list is the store's nothing — there is nothing to do; with
222
- // something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
223
- // first publish of a site whose file is silent warned that its services "were changed
224
- // elsewhere, and site.yml changed too", listing both as nothing.
97
+ // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
98
+ // With nothing stored either there is nothing to do; with something stored, the file
99
+ // is merely behind.
225
100
  if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
226
101
  if (!base) return { action: 'conflict', local, remote }
227
102
 
@@ -231,3 +106,159 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
231
106
  if (localMoved && !remoteMoved) return { action: 'send', local, remote }
232
107
  return { action: 'conflict', local, remote }
233
108
  }
109
+
110
+ /**
111
+ * What a publish records in `deploy.yml` about the language selection: its
112
+ * fingerprint, on EVERY publish — it rides the content push, so what went live is
113
+ * what was agreed.
114
+ *
115
+ * ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
116
+ * selection itself is in `site.yml`.
117
+ *
118
+ * @param {object} siteYml
119
+ * @returns {{publishLanguagesRequest?: string}}
120
+ */
121
+ export function bankLanguages(siteYml) {
122
+ const langs = fingerprintDeclaration(siteYml?.publishLanguages)
123
+ return langs ? { publishLanguagesRequest: langs } : {}
124
+ }
125
+
126
+ /** One service, as an owner reads it: `on`, `off`, `on (grade: pro)`. */
127
+ function describeService(row) {
128
+ if (!row || typeof row !== 'object') return 'nothing set'
129
+ const state = row.enabled === false ? 'off' : 'on'
130
+ const settings =
131
+ row.config && typeof row.config === 'object' ? Object.entries(row.config) : []
132
+ if (!settings.length) return state
133
+ const shown = settings
134
+ .map(([k, v]) => `${k}: ${v !== null && typeof v === 'object' ? '…' : String(v)}`)
135
+ .join(', ')
136
+ return `${state} (${shown})`
137
+ }
138
+
139
+ const rowNamed = (rows, name) =>
140
+ Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
141
+
142
+ /**
143
+ * Decide what a push or publish sends of `site.yml::services` — and, once it has
144
+ * succeeded, what the project records.
145
+ *
146
+ * Per service the file names, three states are compared: the file, the site now
147
+ * (`status.services`), and the record of the last agreement (this backend's entry in
148
+ * `sync.json`). What the owner changed is sent; what the site changed is kept, and
149
+ * offered into `site.yml`; where both changed, the owner is asked. The list sent is the
150
+ * site's own, with only the changed asks applied (`mergeServiceRows`) — so every other
151
+ * service and setting the site holds is sent as it is stored.
152
+ *
153
+ * ⛔ An open decision — an offer declined, a conflict not resolved, or no terminal to
154
+ * ask at — is never sent, and the record keeps the earlier agreement for it, so the
155
+ * next run still sees the site's change rather than reading it as the file's.
156
+ *
157
+ * @param {object} p
158
+ * @param {object} p.client - the backend client (`origin`, `siteStatus`)
159
+ * @param {string} p.siteDir
160
+ * @param {object} p.siteYml - parsed; updated in place when the owner takes the site's services
161
+ * @param {object|null} [p.status] - the site's status, when the caller has just read it
162
+ * @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
163
+ * @param {boolean} [p.interactive] - whether the owner can be asked
164
+ * @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
165
+ * @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
166
+ * @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
167
+ * producer; `after` — call once the push succeeded (or had nothing to send)
168
+ */
169
+ export async function settleServices({
170
+ client,
171
+ siteDir,
172
+ siteYml,
173
+ status,
174
+ offline = false,
175
+ interactive = false,
176
+ confirm,
177
+ say
178
+ }) {
179
+ const nothing = { emit: {}, after: () => {} }
180
+ const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) })
181
+ if (!asks) return nothing
182
+
183
+ const state = readBackendState(siteDir, client.origin)
184
+ const record = Array.isArray(state.services) ? state.services : undefined
185
+ const siteUuid = state.site?.uuid || null
186
+ let read = status
187
+ if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
188
+ const stored = Array.isArray(read?.services) ? read.services : undefined
189
+
190
+ const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
191
+ if (decision.unreadable) {
192
+ say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
193
+ say.dim(' Run `uniweb pull` to take them, then push again.')
194
+ return nothing
195
+ }
196
+
197
+ const askFor = (name) => asks.find((a) => a.name === name)
198
+ const send = [...decision.send]
199
+ let offered = [...decision.adopt]
200
+ const open = []
201
+
202
+ // ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
203
+ // content they asked to push is a separate thing — and never a guess.
204
+ if (decision.conflict.length) {
205
+ say.warn(
206
+ record
207
+ ? "Your site's services and site.yml both changed since your last sync:"
208
+ : 'site.yml asks for services your site has set differently:'
209
+ )
210
+ for (const name of decision.conflict) {
211
+ say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
212
+ }
213
+ if (!interactive) {
214
+ say.dim(" Left as your site has them — run without --non-interactive to choose.")
215
+ open.push(...decision.conflict)
216
+ } else if (await confirm('Use the services in site.yml?', false)) {
217
+ send.push(...decision.conflict)
218
+ } else {
219
+ // Declining to send is not yet a decision to take the site's: offered below.
220
+ offered = [...offered, ...decision.conflict]
221
+ }
222
+ }
223
+
224
+ // The site moved and the file did not: the file is behind. Offered, never done — a
225
+ // push changes `site.yml` only when the owner says so.
226
+ if (offered.length) {
227
+ say.info("Your site's services changed since your last sync:")
228
+ for (const name of offered) {
229
+ say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
230
+ }
231
+ if (interactive && (await confirm('Update site.yml to match?', false))) {
232
+ const services = takeServices(siteYml.services, stored, offered)
233
+ writeSiteConfig(siteDir, { services })
234
+ if (services) siteYml.services = services
235
+ else delete siteYml.services
236
+ say.ok('site.yml updated.')
237
+ } else {
238
+ open.push(...offered)
239
+ }
240
+ }
241
+
242
+ if (send.length) {
243
+ say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
244
+ }
245
+
246
+ // The list to send: the site's rows with the changed asks applied. With the site
247
+ // unreadable and nothing changed, nothing is sent — the record may be stale, and
248
+ // sending it would be asking for what the site may have moved away from.
249
+ const base = stored ?? record ?? (siteUuid ? undefined : [])
250
+ if (!stored && !send.length) {
251
+ return {
252
+ emit: { declareServices: false },
253
+ after: () => {}
254
+ }
255
+ }
256
+ const rows = mergeServiceRows(base, asks.filter((a) => send.includes(a.name)))
257
+ return {
258
+ emit: { serviceRows: rows },
259
+ after: () => {
260
+ const next = recordAfter({ record, agreed: rows, open })
261
+ if (next) updateBackendState(siteDir, client.origin, { services: next })
262
+ }
263
+ }
264
+ }
@@ -1109,8 +1109,8 @@ function recordSiteWorkspace(siteDir, backend, owner) {
1109
1109
  * ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
1110
1110
  * allowance belongs to the site's owner, who may not be the person pushing. Here
1111
1111
  * there is no site yet, so the only workspace in play is the one the site would be
1112
- * created in — `--as <org>` included. Saying "the site owner's workspace" would be
1113
- * incoherent for a site that does not exist.
1112
+ * created in — one named with `--org` included. Saying "the site owner's workspace"
1113
+ * would be incoherent for a site that does not exist.
1114
1114
  *
1115
1115
  * @param {string} body - the raw response body
1116
1116
  * @returns {string|null}
@@ -1125,6 +1125,9 @@ function describeCreateRefusal(body) {
1125
1125
  }
1126
1126
  if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
1127
1127
 
1128
+ const attach = describeAttachRefusal(p)
1129
+ if (attach) return [attach.headline, ...attach.steps].join('\n ')
1130
+
1128
1131
  if (p.reason === 'storage_quota_exceeded') {
1129
1132
  const parts = []
1130
1133
  const used = humanBytes(p.used_bytes)
@@ -1144,6 +1147,63 @@ function describeCreateRefusal(body) {
1144
1147
  return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
1145
1148
  }
1146
1149
 
1150
+ /**
1151
+ * The backend's refusals to ATTACH a foundation to a site, as what happened and what to
1152
+ * do next — or null for any other problem document.
1153
+ *
1154
+ * A site takes any version of a foundation it already uses, and a new one only from a
1155
+ * scope its author owns. The create, the first push and a push that changes the site's
1156
+ * foundation can each be refused:
1157
+ *
1158
+ * `foundation_not_licensed` not yours to attach. `templates` names the app
1159
+ * templates that carry it: start from one in the app,
1160
+ * then clone the site you made. With none, register a
1161
+ * foundation under a scope you own.
1162
+ * `foundation_not_registered` this backend has no such version.
1163
+ *
1164
+ * ⛔ Branch on `reason`, never on the status: a `403` is also a rejected credential, and
1165
+ * "log in again" is the wrong advice for a foundation that is not yours. ⚠️ Until
1166
+ * 2026-10-06 a push printed exactly that for this refusal, and the create printed the
1167
+ * reason's bare name.
1168
+ *
1169
+ * @param {object|null} problem - a parsed problem document
1170
+ * @returns {{ headline: string, steps: string[] } | null}
1171
+ */
1172
+ export function describeAttachRefusal(problem) {
1173
+ if (!problem || typeof problem !== 'object') return null
1174
+ const named = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
1175
+
1176
+ if (problem.reason === 'foundation_not_licensed') {
1177
+ const pkg = named(problem.package) || 'This foundation'
1178
+ const templates = (Array.isArray(problem.templates) ? problem.templates : [])
1179
+ .map((t) => named(t?.name))
1180
+ .filter(Boolean)
1181
+ const SHOWN = 5
1182
+ const list =
1183
+ templates
1184
+ .slice(0, SHOWN)
1185
+ .map((n) => `“${n}”`)
1186
+ .join(', ') + (templates.length > SHOWN ? ` and ${templates.length - SHOWN} more` : '')
1187
+ const steps = templates.length
1188
+ ? [
1189
+ `Start from ${templates.length === 1 ? list : `one of ${list}`} in the app — ` +
1190
+ `${templates.length === 1 ? 'that template carries' : 'those templates carry'} it — ` +
1191
+ "then `uniweb clone <your site's uuid>`."
1192
+ ]
1193
+ : ['Register a foundation under a scope you own, and name it in site.yml.']
1194
+ return { headline: `${pkg} is not yours to attach.`, steps }
1195
+ }
1196
+
1197
+ if (problem.reason === 'foundation_not_registered') {
1198
+ const f = named(problem.foundation) || 'The foundation this site names'
1199
+ return {
1200
+ headline: `${f} is not registered on this backend.`,
1201
+ steps: ['Register it here first (`uniweb register` in its directory), or name a version this backend has.']
1202
+ }
1203
+ }
1204
+ return null
1205
+ }
1206
+
1147
1207
  /**
1148
1208
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
1149
1209
  *
@@ -1759,13 +1819,22 @@ export async function pushSyncPackages({
1759
1819
  // Two unrelated conflicts share HTTP 409, so branch on the machine-readable
1760
1820
  // `reason` — never on `detail`, which is prose the backend may reword.
1761
1821
  let problem = null
1762
- if ((res.status === 409 || res.status === 400 || res.status === 422) && body) {
1822
+ if ((res.status === 409 || res.status === 400 || res.status === 403 || res.status === 422) && body) {
1763
1823
  try {
1764
1824
  problem = JSON.parse(body)
1765
1825
  } catch {
1766
1826
  /* not a problem document */
1767
1827
  }
1768
1828
  }
1829
+ // A foundation the site may not take (`foundation_not_licensed`, a 403) or that this
1830
+ // backend does not have (`foundation_not_registered`) — said, with what to do next.
1831
+ // Before the credential branch below, which a 403 would otherwise reach.
1832
+ const attach = describeAttachRefusal(problem)
1833
+ if (attach) {
1834
+ error(`${label} push refused — ${attach.headline}`)
1835
+ for (const step of attach.steps) note(step)
1836
+ return null
1837
+ }
1769
1838
  // The package carried no identity for records the backend already stores, so
1770
1839
  // applying it would replace every one of them. `ensureItemUuids` is supposed
1771
1840
  // to make this unreachable, so reaching it means that recovery failed — say so
@@ -26,7 +26,8 @@
26
26
  */
27
27
 
28
28
  import { readOrgFlag } from '../utils/args.js'
29
- import { fetchOrgs } from '../utils/registry-orgs.js'
29
+ import { loginCommand } from '../utils/config.js'
30
+ import { fetchOrgs, bareHandle, validateHandle } from '../utils/registry-orgs.js'
30
31
  import { readRegistryAuth } from '../utils/registry-auth.js'
31
32
  import { workspaceHandle, describeWorkspace } from './client.js'
32
33
 
@@ -65,12 +66,14 @@ export const SOURCE_LABEL = {
65
66
  offline: 'not resolved in a preview'
66
67
  }
67
68
 
68
- const CHOOSE = [
69
- 'Choose the workspace you work in:',
70
- ' uniweb login --org @acme (or --personal) — for every command after',
71
- ' --org @acme / --personal — for this command only',
72
- ` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
73
- ].join('\n')
69
+ /** How to choose, for a command on `origin` — its login names the backend when it must (`loginCommand`). */
70
+ const choose = (origin) =>
71
+ [
72
+ 'Choose the workspace you work in:',
73
+ ` ${loginCommand(origin)} --org @acme (or --personal) — for every command after`,
74
+ ' --org @acme / --personal — for this command only',
75
+ ` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
76
+ ].join('\n')
74
77
 
75
78
  /**
76
79
  * The workspace this command works in.
@@ -119,10 +122,38 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
119
122
  if (!orgs.length) return { workspace: null, source: 'personal' }
120
123
  return {
121
124
  refused: true,
122
- reason: `You belong to organizations, so no workspace is assumed.\n ${CHOOSE}`
125
+ reason: `You belong to organizations, so no workspace is assumed.\n ${choose(client.origin)}`
123
126
  }
124
127
  }
125
128
 
129
+ /**
130
+ * The scope a bare name registers under by default — the workspace the command works in
131
+ * *[Diego, 2026-10-06]*: an organization's handle, or your own handle for your personal
132
+ * workspace.
133
+ *
134
+ * ⭐ A default, asked once: a name that carries a scope keeps it, and `--scope` names
135
+ * another. The workspace still never decides anything else about a foundation — it
136
+ * decides which SITE a command works on; here it only answers the question a bare name
137
+ * asks the first time it registers.
138
+ *
139
+ * Null when the workspace names no scope, and the caller derives one as before
140
+ * (`deriveScope`): none is chosen (you belong to organizations and none is named), the
141
+ * command is a preview, the workspace is a unit without a handle, or the account has no
142
+ * handle (a service account).
143
+ *
144
+ * @param {{ workspace?: string|null, source?: string, refused?: boolean }} ws -
145
+ * `resolveWorkspace`'s answer
146
+ * @param {string|null} accountHandle - the account's own handle (`GET /dev/orgs`), read
147
+ * only for the personal workspace
148
+ * @returns {string|null} `@handle`
149
+ */
150
+ export function scopeOfWorkspace(ws, accountHandle) {
151
+ if (!ws || ws.refused || ws.source === 'offline') return null
152
+ if (typeof ws.workspace === 'string') return ws.workspace.startsWith('@') ? ws.workspace : null
153
+ const h = bareHandle(accountHandle || '')
154
+ return h && !validateHandle(h) ? `@${h}` : null
155
+ }
156
+
126
157
  /**
127
158
  * The workspace a login works in — asked once, at `uniweb login`, and stored with the
128
159
  * session.
@@ -166,11 +197,17 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
166
197
  }
167
198
  }
168
199
 
169
- const { isNonInteractive } = await import('../utils/interactive.js')
200
+ const { isNonInteractive, getCliPrefix } = await import('../utils/interactive.js')
201
+ // ⭐ How to choose, said whole: the session is stored already (`registry-auth.js`, the
202
+ // login methods), so the login that finishes this signs nobody in again.
203
+ const others = [...mine.slice(1).map((h) => `--org ${h}`), '--personal']
204
+ const howToChoose =
205
+ `Choose one: ${loginCommand(apiBase, getCliPrefix())} --org ${mine[0]} (or ${others.join(', ')}) — ` +
206
+ 'it will not ask you to sign in again.'
170
207
  if (isNonInteractive(args)) {
171
208
  return {
172
209
  refused: true,
173
- reason: `You belong to organizations — name the workspace you work in: ${[...mine, '--personal'].map((w) => (w.startsWith('@') ? `--org ${w}` : w)).join(' | ')}.`
210
+ reason: `You belong to organizations, so no workspace is assumed. ${howToChoose}`
174
211
  }
175
212
  }
176
213
  const prompts = (await import('prompts')).default
@@ -188,12 +225,10 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
188
225
  ],
189
226
  initial: 0
190
227
  },
191
- {
192
- onCancel: () => {
193
- console.error('\nCancelled.')
194
- process.exit(0)
195
- }
196
- }
228
+ // ⛔ A cancelled pick is NOT a cancelled login: the session is stored already, so the
229
+ // login says so and how to finish it (`finishLogin`), and exits 2. Until 2026-10-06 this
230
+ // printed "Cancelled." and exited 0 — with the new session in place of the old one.
231
+ { onCancel: () => false }
197
232
  )
198
- return choice ? { choice } : { refused: true, reason: 'No workspace chosen.' }
233
+ return choice ? { choice } : { refused: true, reason: `No workspace chosen. ${howToChoose}` }
199
234
  }