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.
- package/package.json +7 -7
- package/partials/agents.md +35 -14
- package/src/backend/client.js +84 -13
- package/src/backend/foundation-bring-along.js +81 -11
- package/src/backend/service-request.js +204 -173
- package/src/backend/site-sync.js +72 -3
- package/src/backend/workspace.js +52 -17
- package/src/commands/publish.js +39 -159
- package/src/commands/push.js +28 -4
- package/src/commands/register.js +64 -19
- package/src/commands/status.js +32 -3
- package/src/framework-index.json +6 -6
- package/src/index.js +28 -10
- package/src/utils/config.js +61 -6
- package/src/utils/registry-auth.js +18 -5
- package/src/utils/registry-orgs.js +43 -6
|
@@ -1,68 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The requests a push or publish carries — what the owner asks for, and whether it
|
|
3
|
+
* is new.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Two of them, kept by two mechanisms:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
|
|
8
|
+
* 2026-10-06]*. Sent as the site's own list with the owner's CHANGED asks applied,
|
|
9
|
+
* decided per service by comparing the file, the site now and the record of the
|
|
10
|
+
* last agreement in `sync.json` (`settleServices`; spec:
|
|
11
|
+
* kb/framework/reference/site-services-request.md).
|
|
12
|
+
* - **The language selection** — `site.yml::publishLanguages` — told apart from the
|
|
13
|
+
* status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
* is
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* **overwrites the stored request**, including a decision the owner made in the
|
|
17
|
-
* app, which is where the consent workflow's publish happens.
|
|
15
|
+
* ⭐ The model both follow — *"the services in `site.yml` are a request, never a
|
|
16
|
+
* tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file. An
|
|
17
|
+
* unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
|
|
18
|
+
* re-send would overwrite a decision the owner made in the app since.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ It was mostly inert until 2026-09-08: `enabled` was honoured for `api` alone,
|
|
24
|
-
* so a stale re-send of the other names overwrote rows nothing read. That stopped
|
|
25
|
-
* being true the same day, and `api` was never inert — an `enabled: false` on it
|
|
26
|
-
* with a live plan schedules a paid service to end.
|
|
27
|
-
*
|
|
28
|
-
* ## What "changed" is measured against
|
|
29
|
-
*
|
|
30
|
-
* The last block we are known to have sent, recorded in **`deploy.yml`** — a
|
|
31
|
-
* COMMITTED project file that travels with a clone.
|
|
32
|
-
*
|
|
33
|
-
* ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
|
|
34
|
-
* wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
|
|
35
|
-
* has no base at all — and this workflow is multi-machine by construction (consent
|
|
36
|
-
* in a browser, publish from the app). A base that vanishes turns this gate into
|
|
37
|
-
* either the original defect or a silently dropped request.
|
|
38
|
-
*
|
|
39
|
-
* ## ⛔ A HASH, never the block
|
|
40
|
-
*
|
|
41
|
-
* `$secrets` names every secret the site has (values are the `#ref` marker, never
|
|
42
|
-
* secret material — the old wording here said otherwise), and `$services[].config` is opaque and
|
|
43
|
-
* per-service — anything may be in it. `deploy.yml` is committed, so recording
|
|
44
|
-
* either verbatim would write them into git. Equality is all this gate needs;
|
|
45
|
-
* *what* differs is a question for the backend's own copy of the request, not for
|
|
46
|
-
* a mirror of our own.
|
|
20
|
+
* ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
|
|
21
|
+
* nobody edits, so a CLI user could ask for nothing; and this module compared them by
|
|
22
|
+
* a fingerprint in `deploy.yml`, which could not tell who moved.*
|
|
47
23
|
*
|
|
48
24
|
* @module
|
|
49
25
|
*/
|
|
50
26
|
|
|
51
27
|
import { createHash } from 'node:crypto'
|
|
28
|
+
import {
|
|
29
|
+
readServicesRequest,
|
|
30
|
+
takeServices,
|
|
31
|
+
mergeServiceRows,
|
|
32
|
+
reconcileServices,
|
|
33
|
+
recordAfter,
|
|
34
|
+
readBackendState,
|
|
35
|
+
updateBackendState,
|
|
36
|
+
writeSiteConfig
|
|
37
|
+
} from '@uniweb/build/uwx'
|
|
52
38
|
|
|
53
39
|
/**
|
|
54
|
-
* A stable fingerprint of one declared
|
|
40
|
+
* A stable fingerprint of one declared value, or `null` when the key is absent.
|
|
55
41
|
*
|
|
56
|
-
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so:
|
|
57
|
-
*
|
|
58
|
-
* function only has to preserve that distinction — the destructive difference
|
|
59
|
-
* between them is the backend's.
|
|
42
|
+
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
|
|
43
|
+
* language selection, no key means "every declared language" and `[]` means none.
|
|
60
44
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
45
|
+
* List entries are ordered by their serialized form and object keys sorted, so
|
|
46
|
+
* reordering them in the file is not a change.
|
|
63
47
|
*
|
|
64
|
-
* @param {*} declared
|
|
65
|
-
* `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
|
|
48
|
+
* @param {*} declared
|
|
66
49
|
* @returns {string|null} 16 hex chars, or null when undeclared
|
|
67
50
|
*/
|
|
68
51
|
export function fingerprintDeclaration(declared) {
|
|
@@ -70,13 +53,10 @@ export function fingerprintDeclaration(declared) {
|
|
|
70
53
|
const canonical = Array.isArray(declared)
|
|
71
54
|
? declared.map(stableString).sort()
|
|
72
55
|
: [stableString(declared)]
|
|
73
|
-
return createHash('sha256')
|
|
74
|
-
.update(JSON.stringify(canonical))
|
|
75
|
-
.digest('hex')
|
|
76
|
-
.slice(0, 16)
|
|
56
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
|
|
77
57
|
}
|
|
78
58
|
|
|
79
|
-
/** Absent, or an empty list — a stored
|
|
59
|
+
/** Absent, or an empty list — a stored value that asks for nothing. */
|
|
80
60
|
function isNothing(value) {
|
|
81
61
|
return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
|
|
82
62
|
}
|
|
@@ -91,125 +71,22 @@ function stableString(value) {
|
|
|
91
71
|
}
|
|
92
72
|
|
|
93
73
|
/**
|
|
94
|
-
*
|
|
95
|
-
* them. Keys are omitted rather than set to null, so an undeclared block leaves no
|
|
96
|
-
* trace in the file.
|
|
97
|
-
*
|
|
98
|
-
* @param {object} siteYml
|
|
99
|
-
* @returns {{servicesRequest?: string, secretsRequest?: string}}
|
|
100
|
-
*/
|
|
101
|
-
export function fingerprintRequest(siteYml, provisioned = {}) {
|
|
102
|
-
const out = {}
|
|
103
|
-
// ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
|
|
104
|
-
// backend being published to (they were `site.yml::$services` / `$secrets` until
|
|
105
|
-
// 2026-09-20). The language selection below is still authored, so still site.yml.
|
|
106
|
-
const services = fingerprintDeclaration(provisioned?.services)
|
|
107
|
-
if (services) out.servicesRequest = services
|
|
108
|
-
const secrets = fingerprintDeclaration(provisioned?.secrets)
|
|
109
|
-
if (secrets) out.secretsRequest = secrets
|
|
110
|
-
// ⭐ The language selection is a request too, and it is the one that moves a
|
|
111
|
-
// PRICE — the line is billed on how many languages go out. Banking it is what
|
|
112
|
-
// lets a later edit read as "the owner asked for another language" rather than
|
|
113
|
-
// as a value that was always there.
|
|
114
|
-
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
115
|
-
if (langs) out.publishLanguagesRequest = langs
|
|
116
|
-
return out
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Should this push DECLARE the request blocks?
|
|
121
|
-
*
|
|
122
|
-
* ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
|
|
123
|
-
* that decides it:
|
|
124
|
-
*
|
|
125
|
-
* 1. It is the behaviour every CLI has had, so nothing regresses.
|
|
126
|
-
* 2. A project may legitimately have no `deploy.yml` (never published, or
|
|
127
|
-
* `autoSave: off`), and that is not evidence about the request.
|
|
128
|
-
* 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
|
|
129
|
-
* failure directions, sending an unchanged block writes back what is usually
|
|
130
|
-
* already there, while withholding a changed one leaves an owner's edit with
|
|
131
|
-
* no effect and nothing said. The loud failure is the better one.
|
|
132
|
-
*
|
|
133
|
-
* @param {object} siteYml - the parsed site.yml
|
|
134
|
-
* @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
|
|
135
|
-
* @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
|
|
136
|
-
*/
|
|
137
|
-
export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
|
|
138
|
-
const now = fingerprintRequest(siteYml, provisioned)
|
|
139
|
-
if (!now.servicesRequest && !now.secretsRequest) {
|
|
140
|
-
// Nothing in the file to send. The gate is moot; say so rather than
|
|
141
|
-
// reporting "unchanged", which would imply a comparison happened.
|
|
142
|
-
return { declare: true, reason: 'undeclared' }
|
|
143
|
-
}
|
|
144
|
-
if (!lastDeploy || typeof lastDeploy !== 'object') {
|
|
145
|
-
return { declare: true, reason: 'no-record' }
|
|
146
|
-
}
|
|
147
|
-
const same =
|
|
148
|
-
now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
|
|
149
|
-
now.secretsRequest === (lastDeploy.secretsRequest || undefined)
|
|
150
|
-
return same
|
|
151
|
-
? { declare: false, reason: 'unchanged' }
|
|
152
|
-
: { declare: true, reason: 'changed' }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* The four-way reconcile, once the backend's own copy of the request is in hand.
|
|
157
|
-
*
|
|
158
|
-
* ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
|
|
159
|
-
* compares the file to our MEMORY of what we last sent, which leaves a window: a
|
|
160
|
-
* request changed in the app between a `uniweb pull` and the next publish reads as
|
|
161
|
-
* unchanged-from-nothing. Comparing to the backend's own rows closes it, because
|
|
162
|
-
* the base stops being something we have to remember correctly.
|
|
163
|
-
*
|
|
164
|
-
* ⛔ It still needs the banked fingerprint. Local and remote differing says the two
|
|
165
|
-
* disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
|
|
166
|
-
* the difference between "the app decided, adopt it" and "you edited, send it".
|
|
167
|
-
*
|
|
168
|
-
* ## The four outcomes
|
|
169
|
-
*
|
|
170
|
-
* | local | remote | |
|
|
171
|
-
* |---|---|---|
|
|
172
|
-
* | unchanged | unchanged | `none` — nobody asked anything |
|
|
173
|
-
* | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
|
|
174
|
-
* | **edited** | unchanged | `send` — a real request |
|
|
175
|
-
* | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
|
|
176
|
-
*
|
|
177
|
-
* ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
|
|
178
|
-
* schedules a paid service to end is not a tie-break, it is a coin toss with the
|
|
179
|
-
* owner's money.
|
|
180
|
-
*
|
|
181
|
-
* ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
|
|
182
|
-
* conservative reading of a difference: if the two differ, `conflict`; if they
|
|
183
|
-
* agree, `none`. That withholds rather than sends, which is safe HERE — unlike
|
|
184
|
-
* `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
|
|
185
|
-
*
|
|
186
|
-
* @param {object} siteYml
|
|
187
|
-
* @param {*} remoteServices - the status read's `services` rows, or undefined
|
|
188
|
-
* @param {object|null} lastDeploy - the banked base
|
|
189
|
-
* @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
|
|
190
|
-
*/
|
|
191
|
-
export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
|
|
192
|
-
return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The same reconcile over any two-way request field.
|
|
74
|
+
* A two-way request field, reconciled against what the site has stored.
|
|
197
75
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* on the other side where something else may move it.
|
|
76
|
+
* `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
|
|
77
|
+
* and stored on the other side where something else may move it.
|
|
201
78
|
*
|
|
202
|
-
* ⛔
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* charge, and saying so before sending requires knowing it is new.
|
|
79
|
+
* ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
|
|
80
|
+
* only thing a base is for — without one, a value that has always been in the file
|
|
81
|
+
* is indistinguishable from one the owner just typed, so the CLI cannot tell an
|
|
82
|
+
* intentional change from the status quo. For languages that difference is money:
|
|
83
|
+
* the count is priced, so a new language is a charge, and saying so before sending
|
|
84
|
+
* requires knowing it is new.
|
|
209
85
|
*
|
|
210
86
|
* @param {*} localValue - the file's declaration
|
|
211
87
|
* @param {*} remoteValue - what the site has stored
|
|
212
88
|
* @param {string|null} baseFingerprint - what we last agreed on
|
|
89
|
+
* @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
|
|
213
90
|
*/
|
|
214
91
|
export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
215
92
|
const local = fingerprintDeclaration(localValue)
|
|
@@ -217,11 +94,9 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
|
217
94
|
const base = baseFingerprint || null
|
|
218
95
|
|
|
219
96
|
if (local === remote) return { action: 'none', local, remote }
|
|
220
|
-
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
|
|
221
|
-
// nothing stored either
|
|
222
|
-
//
|
|
223
|
-
// first publish of a site whose file is silent warned that its services "were changed
|
|
224
|
-
// elsewhere, and site.yml changed too", listing both as nothing.
|
|
97
|
+
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
|
|
98
|
+
// With nothing stored either there is nothing to do; with something stored, the file
|
|
99
|
+
// is merely behind.
|
|
225
100
|
if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
|
|
226
101
|
if (!base) return { action: 'conflict', local, remote }
|
|
227
102
|
|
|
@@ -231,3 +106,159 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
|
231
106
|
if (localMoved && !remoteMoved) return { action: 'send', local, remote }
|
|
232
107
|
return { action: 'conflict', local, remote }
|
|
233
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What a publish records in `deploy.yml` about the language selection: its
|
|
112
|
+
* fingerprint, on EVERY publish — it rides the content push, so what went live is
|
|
113
|
+
* what was agreed.
|
|
114
|
+
*
|
|
115
|
+
* ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
|
|
116
|
+
* selection itself is in `site.yml`.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} siteYml
|
|
119
|
+
* @returns {{publishLanguagesRequest?: string}}
|
|
120
|
+
*/
|
|
121
|
+
export function bankLanguages(siteYml) {
|
|
122
|
+
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
123
|
+
return langs ? { publishLanguagesRequest: langs } : {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One service, as an owner reads it: `on`, `off`, `on (grade: pro)`. */
|
|
127
|
+
function describeService(row) {
|
|
128
|
+
if (!row || typeof row !== 'object') return 'nothing set'
|
|
129
|
+
const state = row.enabled === false ? 'off' : 'on'
|
|
130
|
+
const settings =
|
|
131
|
+
row.config && typeof row.config === 'object' ? Object.entries(row.config) : []
|
|
132
|
+
if (!settings.length) return state
|
|
133
|
+
const shown = settings
|
|
134
|
+
.map(([k, v]) => `${k}: ${v !== null && typeof v === 'object' ? '…' : String(v)}`)
|
|
135
|
+
.join(', ')
|
|
136
|
+
return `${state} (${shown})`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rowNamed = (rows, name) =>
|
|
140
|
+
Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Decide what a push or publish sends of `site.yml::services` — and, once it has
|
|
144
|
+
* succeeded, what the project records.
|
|
145
|
+
*
|
|
146
|
+
* Per service the file names, three states are compared: the file, the site now
|
|
147
|
+
* (`status.services`), and the record of the last agreement (this backend's entry in
|
|
148
|
+
* `sync.json`). What the owner changed is sent; what the site changed is kept, and
|
|
149
|
+
* offered into `site.yml`; where both changed, the owner is asked. The list sent is the
|
|
150
|
+
* site's own, with only the changed asks applied (`mergeServiceRows`) — so every other
|
|
151
|
+
* service and setting the site holds is sent as it is stored.
|
|
152
|
+
*
|
|
153
|
+
* ⛔ An open decision — an offer declined, a conflict not resolved, or no terminal to
|
|
154
|
+
* ask at — is never sent, and the record keeps the earlier agreement for it, so the
|
|
155
|
+
* next run still sees the site's change rather than reading it as the file's.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} p
|
|
158
|
+
* @param {object} p.client - the backend client (`origin`, `siteStatus`)
|
|
159
|
+
* @param {string} p.siteDir
|
|
160
|
+
* @param {object} p.siteYml - parsed; updated in place when the owner takes the site's services
|
|
161
|
+
* @param {object|null} [p.status] - the site's status, when the caller has just read it
|
|
162
|
+
* @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
|
|
163
|
+
* @param {boolean} [p.interactive] - whether the owner can be asked
|
|
164
|
+
* @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
|
|
165
|
+
* @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
|
|
166
|
+
* @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
|
|
167
|
+
* producer; `after` — call once the push succeeded (or had nothing to send)
|
|
168
|
+
*/
|
|
169
|
+
export async function settleServices({
|
|
170
|
+
client,
|
|
171
|
+
siteDir,
|
|
172
|
+
siteYml,
|
|
173
|
+
status,
|
|
174
|
+
offline = false,
|
|
175
|
+
interactive = false,
|
|
176
|
+
confirm,
|
|
177
|
+
say
|
|
178
|
+
}) {
|
|
179
|
+
const nothing = { emit: {}, after: () => {} }
|
|
180
|
+
const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) })
|
|
181
|
+
if (!asks) return nothing
|
|
182
|
+
|
|
183
|
+
const state = readBackendState(siteDir, client.origin)
|
|
184
|
+
const record = Array.isArray(state.services) ? state.services : undefined
|
|
185
|
+
const siteUuid = state.site?.uuid || null
|
|
186
|
+
let read = status
|
|
187
|
+
if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
|
|
188
|
+
const stored = Array.isArray(read?.services) ? read.services : undefined
|
|
189
|
+
|
|
190
|
+
const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
|
|
191
|
+
if (decision.unreadable) {
|
|
192
|
+
say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
|
|
193
|
+
say.dim(' Run `uniweb pull` to take them, then push again.')
|
|
194
|
+
return nothing
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const askFor = (name) => asks.find((a) => a.name === name)
|
|
198
|
+
const send = [...decision.send]
|
|
199
|
+
let offered = [...decision.adopt]
|
|
200
|
+
const open = []
|
|
201
|
+
|
|
202
|
+
// ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
|
|
203
|
+
// content they asked to push is a separate thing — and never a guess.
|
|
204
|
+
if (decision.conflict.length) {
|
|
205
|
+
say.warn(
|
|
206
|
+
record
|
|
207
|
+
? "Your site's services and site.yml both changed since your last sync:"
|
|
208
|
+
: 'site.yml asks for services your site has set differently:'
|
|
209
|
+
)
|
|
210
|
+
for (const name of decision.conflict) {
|
|
211
|
+
say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
|
|
212
|
+
}
|
|
213
|
+
if (!interactive) {
|
|
214
|
+
say.dim(" Left as your site has them — run without --non-interactive to choose.")
|
|
215
|
+
open.push(...decision.conflict)
|
|
216
|
+
} else if (await confirm('Use the services in site.yml?', false)) {
|
|
217
|
+
send.push(...decision.conflict)
|
|
218
|
+
} else {
|
|
219
|
+
// Declining to send is not yet a decision to take the site's: offered below.
|
|
220
|
+
offered = [...offered, ...decision.conflict]
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The site moved and the file did not: the file is behind. Offered, never done — a
|
|
225
|
+
// push changes `site.yml` only when the owner says so.
|
|
226
|
+
if (offered.length) {
|
|
227
|
+
say.info("Your site's services changed since your last sync:")
|
|
228
|
+
for (const name of offered) {
|
|
229
|
+
say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
|
|
230
|
+
}
|
|
231
|
+
if (interactive && (await confirm('Update site.yml to match?', false))) {
|
|
232
|
+
const services = takeServices(siteYml.services, stored, offered)
|
|
233
|
+
writeSiteConfig(siteDir, { services })
|
|
234
|
+
if (services) siteYml.services = services
|
|
235
|
+
else delete siteYml.services
|
|
236
|
+
say.ok('site.yml updated.')
|
|
237
|
+
} else {
|
|
238
|
+
open.push(...offered)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (send.length) {
|
|
243
|
+
say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The list to send: the site's rows with the changed asks applied. With the site
|
|
247
|
+
// unreadable and nothing changed, nothing is sent — the record may be stale, and
|
|
248
|
+
// sending it would be asking for what the site may have moved away from.
|
|
249
|
+
const base = stored ?? record ?? (siteUuid ? undefined : [])
|
|
250
|
+
if (!stored && !send.length) {
|
|
251
|
+
return {
|
|
252
|
+
emit: { declareServices: false },
|
|
253
|
+
after: () => {}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const rows = mergeServiceRows(base, asks.filter((a) => send.includes(a.name)))
|
|
257
|
+
return {
|
|
258
|
+
emit: { serviceRows: rows },
|
|
259
|
+
after: () => {
|
|
260
|
+
const next = recordAfter({ record, agreed: rows, open })
|
|
261
|
+
if (next) updateBackendState(siteDir, client.origin, { services: next })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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 — `--
|
|
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
|
package/src/backend/workspace.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { readOrgFlag } from '../utils/args.js'
|
|
29
|
-
import {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
233
|
+
return choice ? { choice } : { refused: true, reason: `No workspace chosen. ${howToChoose}` }
|
|
199
234
|
}
|