uniweb 1.0.0 → 1.0.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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackendClient — the single front door to the Uniweb backend.
|
|
3
|
+
*
|
|
4
|
+
* Every CLI verb that talks to the backend (register, push, pull, clone, org,
|
|
5
|
+
* login — and, as the consolidation proceeds, publish/deploy/invite/handoff)
|
|
6
|
+
* goes through one instance of this client instead of hand-rolling
|
|
7
|
+
* `fetch(… Authorization: Bearer …)` against a per-command default origin. It
|
|
8
|
+
* owns the three things that were previously scattered across a dozen files:
|
|
9
|
+
*
|
|
10
|
+
* 1. ORIGIN — where the backend is. resolveBackendOrigin(): UNIWEB_SERVER >
|
|
11
|
+
* the backend you are logged in to > the default backend (below).
|
|
12
|
+
* Each is reduced to its origin, http(s) only. *(This named a
|
|
13
|
+
* `--backend` flag as the first tier until 2026-10-05; the verbs
|
|
14
|
+
* lost that flag on 2026-09-21.)*
|
|
15
|
+
* 2. AUTH — the opaque session bearer (utils/registry-auth.js). Resolved
|
|
16
|
+
* LAZILY on the first authed call, so dry-runs and fully-local
|
|
17
|
+
* work never trigger a login.
|
|
18
|
+
* 3. REQUESTS — one request() helper (auth header, query params, body
|
|
19
|
+
* content-type) plus a typed method per backend operation.
|
|
20
|
+
*
|
|
21
|
+
* The legacy platform paths (the old per-verb URL resolvers, the separate
|
|
22
|
+
* token-based auth, and the remote/local registry classes) are deliberately
|
|
23
|
+
* NOT represented here — consolidating every verb onto this client is what
|
|
24
|
+
* retires them.
|
|
25
|
+
*
|
|
26
|
+
* During Phase 1 the typed methods delegate to the existing, well-tested
|
|
27
|
+
* helpers (registry-auth.js, registry-orgs.js, code-upload.js); those modules
|
|
28
|
+
* move under backend/ as the consolidation lands, leaving this client as their
|
|
29
|
+
* single home.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { getRegistryApiBaseUrl, loginCommand } from '../utils/config.js'
|
|
33
|
+
import {
|
|
34
|
+
ensureRegistryAuth,
|
|
35
|
+
fetchMe,
|
|
36
|
+
readRegistryAuth,
|
|
37
|
+
isExpired
|
|
38
|
+
} from '../utils/registry-auth.js'
|
|
39
|
+
import {
|
|
40
|
+
fetchOrgs as fetchOrgsImpl,
|
|
41
|
+
createOrg as createOrgImpl
|
|
42
|
+
} from '../utils/registry-orgs.js'
|
|
43
|
+
import { uploadFoundationCode } from '../utils/code-upload.js'
|
|
44
|
+
import { uploadSiteAssets } from '../utils/asset-upload.js'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the backend a command talks to:
|
|
48
|
+
*
|
|
49
|
+
* 1. UNIWEB_SERVER env — the override for automation (CI, scripts), one process
|
|
50
|
+
* 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
|
|
51
|
+
* 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
|
|
52
|
+
* where the command's first request then asks the user to log in
|
|
53
|
+
*
|
|
54
|
+
* ⭐ **That is the whole ladder, for every command** *[Diego, 2026-09-21: "push and pull
|
|
55
|
+
* are also meant to go to the backend you are logged into" · "We do not allow any
|
|
56
|
+
* communication with backend if the user is not logged into a backend. The default
|
|
57
|
+
* backend for login, if not specified, is uniweb.app" · switching "via login to another
|
|
58
|
+
* backend is good, and the only way to switch"]*. Logging in is how a backend is chosen.
|
|
59
|
+
*
|
|
60
|
+
* ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
|
|
61
|
+
* `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
|
|
62
|
+
* slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
|
|
63
|
+
* a script aims with UNIWEB_SERVER without touching the machine's login. A
|
|
64
|
+
* project's sync.json and deploy.yml routed commands too, until the same day.
|
|
65
|
+
* A backend flag survives only where it SELECTS rather than routes — `--server`, `--backend`
|
|
66
|
+
* until 2026-10-07: `login` (where to log
|
|
67
|
+
* in) and `forget` (which backend's records to remove). `logout` needs none: there is
|
|
68
|
+
* one session.
|
|
69
|
+
*
|
|
70
|
+
* @returns {string} a bare origin with no trailing slash
|
|
71
|
+
*/
|
|
72
|
+
export function resolveBackendOrigin() {
|
|
73
|
+
return getRegistryApiBaseUrl()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The fallback capability doc for when `GET /dev/config` was not asked for (no
|
|
78
|
+
* credential in hand) or did not answer. Keeps the client non-breaking.
|
|
79
|
+
*
|
|
80
|
+
* ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads only that document's
|
|
81
|
+
* `siteContent` key lists — `queryFields`, `pageFields`, `settingsFields` — and their absence
|
|
82
|
+
* is meaningful: unknown means a push sends no optional key, which every deployment accepts,
|
|
83
|
+
* rather than claiming a deployment takes one. Every other key that used to sit here had no
|
|
84
|
+
* reader. *(The leaf named here was `delivery.siteSubscriptionRequired` until 2026-09-26; it
|
|
85
|
+
* went 2026-08-30.)*
|
|
86
|
+
*
|
|
87
|
+
* ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
|
|
88
|
+
* a reader waiting to happen, and it is how this file came to describe a client that
|
|
89
|
+
* discovered its backend's gateway base, asset base and login path — none of which was
|
|
90
|
+
* ever true. The removals, and why each was not merely unused but wrong:
|
|
91
|
+
*
|
|
92
|
+
* `gatewayBase` sat here UNREAD until 2026-07-29. A serve location is read from the
|
|
93
|
+
* response that carries it (an upload plan's `serve_base`, an asset
|
|
94
|
+
* entry's `serve_url`, a payload's `config.base`) — never from a
|
|
95
|
+
* handshake, which cannot know a per-response answer.
|
|
96
|
+
* `assetBase` until 2026-08-17: one production host, hardcoded, applied to every
|
|
97
|
+
* deployment the CLI can be pointed at. Read only to compose an asset
|
|
98
|
+
* URL the plan already returns verbatim. Reader and composer both gone.
|
|
99
|
+
* `runtime` until 2026-08-22. A backend does not hold runtimes — a version comes
|
|
100
|
+
* from a CDN — so there is no installed set to report. What a site gets
|
|
101
|
+
* follows from its foundation's floor (`info.runtime`, at register).
|
|
102
|
+
* `auth` `loginPath` was never read: the login path is a constant in
|
|
103
|
+
* `utils/registry-auth.js`, and the ORIGIN comes from the resolution
|
|
104
|
+
* ladder, so the CLI is never told where to log in — it is born knowing.
|
|
105
|
+
* `delivery` `deploy` and `broker` had no reader. `publish` had one, but it could
|
|
106
|
+
* never refuse: the backend sent a literal true for every deployment,
|
|
107
|
+
* so the gate read a constant. Removed on both sides 2026-08-30.
|
|
108
|
+
* `assets` `supported` had no reader; the asset lane reports its own capability
|
|
109
|
+
* through the upload plan it returns.
|
|
110
|
+
*/
|
|
111
|
+
export const DISCOVERY_DEFAULTS = {}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The header a request names its WORKSPACE with — the unit it works in.
|
|
115
|
+
*
|
|
116
|
+
* ⭐ `@<handle>`, the `@` required: a bare value is read as a unit's uuid — the spelling
|
|
117
|
+
* for a workspace with no handle — so the two can never be mistaken for each other.
|
|
118
|
+
* Every `/dev` route accepts it; the client names it on the site routes, the ones that
|
|
119
|
+
* work in a workspace (`namesWorkspace`). ⛔ It replaced `?as_org=` (2026-09-23), which
|
|
120
|
+
* only routes that declared it accepted — the rest answered `400`.
|
|
121
|
+
*/
|
|
122
|
+
export const WORKSPACE_HEADER = 'x-uniweb-workspace'
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a request works IN a workspace — a site route, `/dev/site…`.
|
|
126
|
+
*
|
|
127
|
+
* Only those name one. Every `/dev` route accepts the header, but registration reads
|
|
128
|
+
* none (the `@scope` in the name decides, membership-gated), `/dev/assets` reads none
|
|
129
|
+
* (a charge lands on the entity's own workspace), and auth, orgs and config are about
|
|
130
|
+
* the account. Naming the site's workspace there would be harmless and meaningless —
|
|
131
|
+
* and a workspace that is refused (`400`/`403`) would then fail a registration that
|
|
132
|
+
* never involved it.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} path - leading-slash path
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function namesWorkspace(path) {
|
|
138
|
+
return /^\/dev\/site(\/|$)/.test(String(path))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A workspace as it may be written — `@acme`, `acme`, `@acme/…` — as the header's
|
|
143
|
+
* `@acme`; null for none (the personal workspace, which a request names by omission).
|
|
144
|
+
*
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
* @returns {string|null}
|
|
147
|
+
*/
|
|
148
|
+
export function workspaceHandle(value) {
|
|
149
|
+
const h =
|
|
150
|
+
typeof value === 'string' ? value.trim().replace(/^@/, '').replace(/\/.*$/, '') : ''
|
|
151
|
+
return h ? `@${h}` : null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal
|
|
158
|
+
* workspace` — for null, and for a login's stored `personal`.
|
|
159
|
+
*/
|
|
160
|
+
export function describeWorkspace(workspace) {
|
|
161
|
+
if (!workspace || workspace === 'personal') return 'your personal workspace'
|
|
162
|
+
return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A workspace as the header names it — `@handle`, or a unit's bare uuid when the unit
|
|
167
|
+
* has no handle — or null for none.
|
|
168
|
+
*
|
|
169
|
+
* ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
|
|
170
|
+
* handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
|
|
171
|
+
* So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
|
|
172
|
+
* login's choice as stored — and a bare uuid here is a unit.
|
|
173
|
+
*
|
|
174
|
+
* @param {unknown} value
|
|
175
|
+
* @returns {string|null}
|
|
176
|
+
*/
|
|
177
|
+
export function workspaceHeader(value) {
|
|
178
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
179
|
+
const v = value.trim()
|
|
180
|
+
return !v.startsWith('@') && UNIT_UUID.test(v) ? v : workspaceHandle(v)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The site is in another workspace than the one this command works in.
|
|
185
|
+
*
|
|
186
|
+
* ⛔ **Stopping is the point, and nothing is adopted.** A command works in ONE workspace —
|
|
187
|
+
* the one chosen with the login (`workspace.js`) — and a site outside it is refused, not
|
|
188
|
+
* worked on anyway *[Diego, 2026-09-23: "that's the intent of the workspace concept"]*.
|
|
189
|
+
* It is the backend's `409` that decides it, never a handle comparison here: a site in
|
|
190
|
+
* `@acme/labs` is legitimately worked on from `@acme`. ⚠️ *Until 2026-09-23 a workspace
|
|
191
|
+
* nobody had named was adopted from the `409` and the request retried.*
|
|
192
|
+
*/
|
|
193
|
+
export class WorkspaceMismatchError extends Error {
|
|
194
|
+
/**
|
|
195
|
+
* @param {{ named: string|null, answer: string|null, source?: string, origin?: string|null }} p -
|
|
196
|
+
* the workspace the request named and the one the backend named (`@handle`, a unit's
|
|
197
|
+
* uuid, null for personal), where the named one came from (`workspace.js`), and the
|
|
198
|
+
* backend — a login that switches must name it unless it is the default (`loginCommand`)
|
|
199
|
+
*/
|
|
200
|
+
constructor({ named, answer, source = 'login', origin = null }) {
|
|
201
|
+
const where = (w) =>
|
|
202
|
+
!w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
|
|
203
|
+
// How to work in the site's workspace, said for the way this one was chosen.
|
|
204
|
+
const switchTo = !answer
|
|
205
|
+
? { flag: 'pass --personal', env: 'set UNIWEB_WORKSPACE=personal' }
|
|
206
|
+
: answer.startsWith('@')
|
|
207
|
+
? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
|
|
208
|
+
: null
|
|
209
|
+
const login = !answer
|
|
210
|
+
? `${loginCommand(origin)} --personal`
|
|
211
|
+
: answer.startsWith('@')
|
|
212
|
+
? `${loginCommand(origin)} --org ${answer}`
|
|
213
|
+
: null
|
|
214
|
+
const fix = !switchTo
|
|
215
|
+
? 'It has no handle, so the CLI cannot work in it.'
|
|
216
|
+
: source === 'flag'
|
|
217
|
+
? `To work on it, ${switchTo.flag}.`
|
|
218
|
+
: source === 'env'
|
|
219
|
+
? `To work on it, ${switchTo.env}.`
|
|
220
|
+
: `To work on it: ${login}`
|
|
221
|
+
const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
|
|
222
|
+
super(`This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
|
|
223
|
+
this.name = 'WorkspaceMismatchError'
|
|
224
|
+
this.status = 409
|
|
225
|
+
this.named = named
|
|
226
|
+
this.answer = answer
|
|
227
|
+
this.source = source
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The backend could not be reached at all — the request never got an answer.
|
|
233
|
+
*
|
|
234
|
+
* ⛔ Not an answer, so it must not be read as one. A lookup that folds this into "not
|
|
235
|
+
* found" sends its caller down the not-found path: `readFoundationLatest` did, and a
|
|
236
|
+
* publish with the backend down announced "Releasing the foundation (not yet
|
|
237
|
+
* registered)…", failed inside `register`, and ended on "Fix the foundation".
|
|
238
|
+
*/
|
|
239
|
+
export class BackendUnreachableError extends Error {
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} origin
|
|
242
|
+
* @param {unknown} cause - the transport error `fetch` threw
|
|
243
|
+
*/
|
|
244
|
+
constructor(origin, cause) {
|
|
245
|
+
super(`Could not reach the backend at ${origin}: ${cause?.message ?? cause}`)
|
|
246
|
+
this.name = 'BackendUnreachableError'
|
|
247
|
+
this.origin = origin
|
|
248
|
+
this.cause = cause
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The line to print when a request THREW rather than answered.
|
|
254
|
+
*
|
|
255
|
+
* A `WorkspaceMismatchError` is the backend's answer, not a transport failure, so it is
|
|
256
|
+
* printed as itself: "Could not reach the backend" in front of it would send the user
|
|
257
|
+
* to check their network over a choice they made on the command line.
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} err
|
|
260
|
+
* @param {string} origin
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
export function describeRequestError(err, origin) {
|
|
264
|
+
if (err instanceof WorkspaceMismatchError) return err.message
|
|
265
|
+
return `Could not reach the backend at ${origin}: ${err?.message ?? err}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The sentence a refusal carries — a problem document's `detail`, else its `title`, else
|
|
270
|
+
* the start of a prose body — or null. Consumes the body. For the surfaces that would
|
|
271
|
+
* otherwise print a status line alone, so a refusal the backend explains is shown as it
|
|
272
|
+
* is: a deployment with one workspace answers a request naming another with a plain
|
|
273
|
+
* `409 Conflict` whose `detail` names the one there is.
|
|
274
|
+
*
|
|
275
|
+
* @param {Response} res
|
|
276
|
+
* @returns {Promise<string|null>}
|
|
277
|
+
*/
|
|
278
|
+
export async function refusalDetail(res) {
|
|
279
|
+
let body = ''
|
|
280
|
+
try {
|
|
281
|
+
body = await res.text()
|
|
282
|
+
} catch {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const problem = JSON.parse(body)
|
|
287
|
+
const line = problem?.detail || problem?.title
|
|
288
|
+
return typeof line === 'string' && line ? line : null
|
|
289
|
+
} catch {
|
|
290
|
+
return body ? body.slice(0, 300) : null
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export class BackendClient {
|
|
295
|
+
/**
|
|
296
|
+
* @param {object} [opts]
|
|
297
|
+
* @param {string} [opts.origin] - explicit origin (wins over the ladder; tests and
|
|
298
|
+
* internal callers — no command takes one from the user)
|
|
299
|
+
* @param {string} [opts.token] - explicit bearer (wins over env + stored session)
|
|
300
|
+
* @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
|
|
301
|
+
* callers with their own auth); used when no explicit token/env is present
|
|
302
|
+
* @param {string[]} [opts.args] - argv slice (checked for --non-interactive in auth)
|
|
303
|
+
* @param {string} [opts.command] - label for the login prompt ('Pushing', 'Registering', …)
|
|
304
|
+
* @param {typeof fetch} [opts.fetchImpl] - injectable fetch (tests)
|
|
305
|
+
*/
|
|
306
|
+
constructor({
|
|
307
|
+
origin,
|
|
308
|
+
token,
|
|
309
|
+
getToken,
|
|
310
|
+
args = [],
|
|
311
|
+
command = 'This command',
|
|
312
|
+
fetchImpl
|
|
313
|
+
} = {}) {
|
|
314
|
+
this.origin = (
|
|
315
|
+
origin || resolveBackendOrigin()
|
|
316
|
+
).replace(/\/+$/, '')
|
|
317
|
+
this._token = token || process.env.UNIWEB_TOKEN || null
|
|
318
|
+
this._getToken = getToken || null
|
|
319
|
+
this._args = args
|
|
320
|
+
this._command = command
|
|
321
|
+
this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
|
|
322
|
+
this._discovery = null
|
|
323
|
+
this._workspace = null
|
|
324
|
+
this._workspaceSource = 'login'
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The workspace this client's requests name — `@handle`, or null (none named). */
|
|
328
|
+
get workspace() {
|
|
329
|
+
return this._workspace
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Name the workspace every site request from here on works in (`x-uniweb-workspace`)
|
|
334
|
+
* — the one `resolveWorkspace` answered for this command.
|
|
335
|
+
*
|
|
336
|
+
* @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null for
|
|
337
|
+
* the personal workspace (`workspaceHeader`)
|
|
338
|
+
* @param {object} [opts]
|
|
339
|
+
* @param {string} [opts.source='login'] - where it came from (`workspace.js`), so a
|
|
340
|
+
* refusal says how to switch
|
|
341
|
+
* @returns {this}
|
|
342
|
+
*/
|
|
343
|
+
setWorkspace(value, { source = 'login' } = {}) {
|
|
344
|
+
this._workspace = workspaceHeader(value)
|
|
345
|
+
this._workspaceSource = source
|
|
346
|
+
return this
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The session bearer, resolved lazily and memoized. Order: an explicit `token`
|
|
351
|
+
* (constructor — tests and internal callers; no command takes one from the user) /
|
|
352
|
+
* UNIWEB_TOKEN > the stored session > interactive login (ensureRegistryAuth).
|
|
353
|
+
* @returns {Promise<string>}
|
|
354
|
+
*/
|
|
355
|
+
async token() {
|
|
356
|
+
if (this._token) return this._token
|
|
357
|
+
if (this._getToken) {
|
|
358
|
+
this._token = await this._getToken()
|
|
359
|
+
return this._token
|
|
360
|
+
}
|
|
361
|
+
// ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
|
|
362
|
+
// It existed because the session store held ONE record, read without an origin: a
|
|
363
|
+
// session for another backend was about to be sent here and rejected, and a warning
|
|
364
|
+
// was the best available move. The session is looked up BY ORIGIN now
|
|
365
|
+
// (`readRegistryAuth(origin)`, utils/registry-auth.js), so another backend's token is
|
|
366
|
+
// never sent here — `ensureRegistryAuth` below finds this origin's session or asks
|
|
367
|
+
// for one. ⚠️ There is still ONE session (since 2026-09-21): a login it asks for
|
|
368
|
+
// replaces the other backend's session, and says so. *(Until 2026-10-05 this read
|
|
369
|
+
// "sessions are keyed by origin now … being logged into another backend is just a
|
|
370
|
+
// fact about another backend" — true of the per-backend store of 2026-09-20→21, and
|
|
371
|
+
// read as "several sessions coexist".)*
|
|
372
|
+
this._token = await ensureRegistryAuth({
|
|
373
|
+
apiBase: this.origin,
|
|
374
|
+
command: this._command,
|
|
375
|
+
args: this._args
|
|
376
|
+
})
|
|
377
|
+
return this._token
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Low-level request against the backend. Adds the bearer (unless auth:false),
|
|
382
|
+
* applies query params, and infers a content-type from the body when unset
|
|
383
|
+
* (string → application/json, Buffer/Uint8Array → application/zip). Returns
|
|
384
|
+
* the raw Response so callers branch on status themselves (409 resume,
|
|
385
|
+
* 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, thrown as
|
|
386
|
+
* `WorkspaceMismatchError`: the site is in another workspace than this command's.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} path - leading-slash path, e.g. '/dev/site/content'
|
|
389
|
+
* @param {object} [opts]
|
|
390
|
+
* @param {string} [opts.method='GET']
|
|
391
|
+
* @param {*} [opts.body]
|
|
392
|
+
* @param {Record<string,string>} [opts.headers]
|
|
393
|
+
* @param {Record<string,string|number|undefined>} [opts.query]
|
|
394
|
+
* @param {boolean} [opts.auth=true]
|
|
395
|
+
* @returns {Promise<Response>}
|
|
396
|
+
*/
|
|
397
|
+
async request(path, opts = {}) {
|
|
398
|
+
const res = await this._send(path, opts)
|
|
399
|
+
if (res?.status !== 409) return res
|
|
400
|
+
|
|
401
|
+
// ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request whose
|
|
402
|
+
// workspace does not contain the site is refused with `409 wrong_workspace`, naming a
|
|
403
|
+
// workspace the caller CAN open that does — the site's own unit, or a parent. The
|
|
404
|
+
// command works in one workspace, so this stops it (`WorkspaceMismatchError`).
|
|
405
|
+
// ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
|
|
406
|
+
const problem =
|
|
407
|
+
typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
|
|
408
|
+
if (problem?.reason !== 'wrong_workspace') return res
|
|
409
|
+
const where = problem.workspace || {}
|
|
410
|
+
// A unit with no handle is named by its uuid, bare — the header takes either.
|
|
411
|
+
const answer = where.handle
|
|
412
|
+
? workspaceHandle(where.handle)
|
|
413
|
+
: typeof where.unit_uuid === 'string' && where.unit_uuid
|
|
414
|
+
? where.unit_uuid
|
|
415
|
+
: null
|
|
416
|
+
// Refused while naming what the backend names: nothing to switch to; the caller reads
|
|
417
|
+
// the 409 like any other.
|
|
418
|
+
if (answer === this._workspace) return res
|
|
419
|
+
throw new WorkspaceMismatchError({
|
|
420
|
+
named: this._workspace,
|
|
421
|
+
answer,
|
|
422
|
+
source: this._workspaceSource,
|
|
423
|
+
origin: this.origin
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** One request, as `request` documents it — naming the client's workspace on a site route. */
|
|
428
|
+
async _send(path, { method = 'GET', body, headers = {}, query, auth = true } = {}) {
|
|
429
|
+
const url = new URL(path, this.origin)
|
|
430
|
+
if (query) {
|
|
431
|
+
for (const [k, v] of Object.entries(query)) {
|
|
432
|
+
if (v != null) url.searchParams.set(k, String(v))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const h = { ...headers }
|
|
436
|
+
if (auth) h.Authorization = `Bearer ${await this.token()}`
|
|
437
|
+
if (this._workspace && namesWorkspace(path)) h[WORKSPACE_HEADER] = this._workspace
|
|
438
|
+
if (body != null && h['Content-Type'] == null) {
|
|
439
|
+
if (typeof body === 'string') h['Content-Type'] = 'application/json'
|
|
440
|
+
else if (body instanceof Uint8Array || Buffer.isBuffer(body))
|
|
441
|
+
h['Content-Type'] = 'application/zip'
|
|
442
|
+
}
|
|
443
|
+
return this._fetch(url.href, { method, headers: h, body })
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Discovery ─────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The session bearer IF one can be had without asking — an explicit `token`, the
|
|
450
|
+
* UNIWEB_TOKEN env var, or a stored unexpired session. Never prompts, never logs in, returns null
|
|
451
|
+
* instead. `token()` is the one that may block; this is for calls that want to be
|
|
452
|
+
* authenticated when possible but must not *cause* an authentication.
|
|
453
|
+
* @returns {Promise<string|null>}
|
|
454
|
+
*/
|
|
455
|
+
async _tokenIfAvailable() {
|
|
456
|
+
if (this._token) return this._token
|
|
457
|
+
// ⛔ The injected resolver must be honoured here too, not just in `token()`.
|
|
458
|
+
// `pull` and `clone` pass one (`deps.getToken`), so skipping it would treat a caller
|
|
459
|
+
// that supplies its own auth as unauthenticated — and, worse, fall through to the
|
|
460
|
+
// machine's stored session, quietly using a DIFFERENT credential than the caller
|
|
461
|
+
// asked for. A throwing resolver is "no token", never a failed command.
|
|
462
|
+
if (this._getToken) {
|
|
463
|
+
try {
|
|
464
|
+
return (await this._getToken()) || null
|
|
465
|
+
} catch {
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const stored = await readRegistryAuth(this.origin)
|
|
471
|
+
if (stored?.token && !isExpired(stored)) return stored.token
|
|
472
|
+
} catch {
|
|
473
|
+
/* advisory — a missing or unreadable session is simply "no token" */
|
|
474
|
+
}
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* GET /dev/config — the capability/handshake document. Lazy + cached for the client's
|
|
480
|
+
* lifetime; a missing route, a 401, or any transport/parse error falls back to
|
|
481
|
+
* DISCOVERY_DEFAULTS, so this can never be the reason a command fails.
|
|
482
|
+
*
|
|
483
|
+
* ⭐ **Authenticated, or not sent at all.** `/dev/*` is the CLI's lane and the CLI is
|
|
484
|
+
* an authenticated client, so this attaches the bearer when it has one and **makes no
|
|
485
|
+
* request when it does not** — which keeps that true by construction rather than by
|
|
486
|
+
* ordering luck, and makes the route moving behind auth a no-op here.
|
|
487
|
+
*
|
|
488
|
+
* ⚠️ It uses `_tokenIfAvailable()` and never `token()`, deliberately: **discovery must
|
|
489
|
+
* never be the thing that triggers a login.** A capability probe that opens a password
|
|
490
|
+
* prompt would be a worse defect than the anonymous call it replaced.
|
|
491
|
+
*
|
|
492
|
+
* ⛔ **Most of this document is deliberately not read.** `gatewayBase` and `assetBase`
|
|
493
|
+
* were dropped because a serve location is read from the response that carries it
|
|
494
|
+
* (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
|
|
495
|
+
* is not read either — the login path is a hardcoded constant
|
|
496
|
+
* (`utils/registry-auth.js`). What is actually consumed is the key lists under
|
|
497
|
+
* `siteContent` — `queryFields`, `pageFields`, `settingsFields`, the keys the deployment's
|
|
498
|
+
* Sections declare (`site-sync.js::deploymentFields`) — which is policy, never an address. Do not
|
|
499
|
+
* add a reader for the rest: each one would be a second place a backend's layout is
|
|
500
|
+
* pinned. (This named `delivery.siteSubscriptionRequired` until 2026-09-26; that leaf
|
|
501
|
+
* went 2026-08-30.)
|
|
502
|
+
*
|
|
503
|
+
* @returns {Promise<object>}
|
|
504
|
+
*/
|
|
505
|
+
async discover() {
|
|
506
|
+
if (this._discovery) return this._discovery
|
|
507
|
+
const bearer = await this._tokenIfAvailable()
|
|
508
|
+
|
|
509
|
+
// ⛔ NO CREDENTIAL ⇒ NO REQUEST. This is the rule made structural rather than
|
|
510
|
+
// incidental: apart from the login routes themselves, the CLI does not touch
|
|
511
|
+
// `/dev/*` without a bearer. The defaults are the honest answer here — we do not
|
|
512
|
+
// know this backend's capabilities and are not entitled to ask yet — and every
|
|
513
|
+
// caller already treats them as non-breaking, so nothing downstream changes.
|
|
514
|
+
if (!bearer) {
|
|
515
|
+
this._discovery = { ...DISCOVERY_DEFAULTS }
|
|
516
|
+
return this._discovery
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const res = await this.request('/dev/config', {
|
|
521
|
+
auth: false,
|
|
522
|
+
headers: { Authorization: `Bearer ${bearer}` }
|
|
523
|
+
})
|
|
524
|
+
this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
|
|
525
|
+
} catch {
|
|
526
|
+
this._discovery = { ...DISCOVERY_DEFAULTS }
|
|
527
|
+
}
|
|
528
|
+
return this._discovery
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Identity ────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
/** GET /dev/auth/me → the account object ({ uuid, username, handle }) or null. */
|
|
534
|
+
async whoami() {
|
|
535
|
+
return fetchMe({ apiBase: this.origin, token: await this.token() })
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── Registry: foundations + data schemas ──────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* POST /dev/registry/register — submit a names-only .uwx document (a
|
|
542
|
+
* foundation schema + the data schemas it renders, or a standalone schemas
|
|
543
|
+
* package). Returns the raw Response (register branches on a 409 "already
|
|
544
|
+
* registered" to resume code delivery).
|
|
545
|
+
* @param {string} uwxJson - the serialized .uwx (a JSON string)
|
|
546
|
+
* @returns {Promise<Response>}
|
|
547
|
+
*/
|
|
548
|
+
async register(uwxJson) {
|
|
549
|
+
return this.request('/dev/registry/register', {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
body: uwxJson
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Deliver a built foundation's dist/ code (plan → PUT-per-file → verify).
|
|
557
|
+
* Thin pass-through to utils/code-upload.js with this client's origin + token.
|
|
558
|
+
* @param {object} opts - { name, version, distDir, files?, onProgress? }
|
|
559
|
+
*/
|
|
560
|
+
async uploadFoundationCode(opts) {
|
|
561
|
+
return uploadFoundationCode({
|
|
562
|
+
apiBase: this.origin,
|
|
563
|
+
token: await this.token(),
|
|
564
|
+
...opts
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* GET /dev/registry/data-schemas/{scope}/{name} — a Model declaration, or
|
|
570
|
+
* null on 404 (the caller then says "register it first"). Accepts `@scope/name`
|
|
571
|
+
* and bare `name`.
|
|
572
|
+
* @param {string} modelName
|
|
573
|
+
* @returns {Promise<object|null>}
|
|
574
|
+
*/
|
|
575
|
+
async readDataSchema(modelName) {
|
|
576
|
+
const res = await this.request(dataSchemaPath(modelName))
|
|
577
|
+
if (res.status === 404) return null
|
|
578
|
+
if (!res.ok)
|
|
579
|
+
throw new Error(
|
|
580
|
+
`Model-read ${modelName} failed: HTTP ${res.status} ${res.statusText}`
|
|
581
|
+
)
|
|
582
|
+
return res.json()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* GET /dev/registry/{scope}/{name} → the latest registered foundation version
|
|
587
|
+
* + its content digest, or null when the backend answers with anything else (callers
|
|
588
|
+
* degrade). ⛔ A backend that cannot be reached is not an answer: that throws
|
|
589
|
+
* `BackendUnreachableError` rather than reading as "not registered".
|
|
590
|
+
*
|
|
591
|
+
* The bare `{scope}/{name}` path resolves the latest foundation version (the
|
|
592
|
+
* data-schema sibling is `/dev/registry/data-schemas/{scope}/{name}`). The
|
|
593
|
+
* backend returns `version` (+ schema ids); we normalize it to `latest_version`
|
|
594
|
+
* for callers and tolerate either key. `digest` is the framework-computed
|
|
595
|
+
* fingerprint the backend stores OPAQUE and echoes here (null when the version
|
|
596
|
+
* carries none) — the freshness signal for publish/status (shipping-model.md
|
|
597
|
+
* §4.1). A bare/unscoped name → null (only `@org/name` can be looked up).
|
|
598
|
+
* @param {string} scopedName
|
|
599
|
+
* @returns {Promise<{ latest_version: string|null, digest: string|null }|null>}
|
|
600
|
+
*/
|
|
601
|
+
async readFoundationLatest(scopedName) {
|
|
602
|
+
const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
|
|
603
|
+
if (!m) return null
|
|
604
|
+
try {
|
|
605
|
+
const res = await this.request(
|
|
606
|
+
`/dev/registry/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
|
|
607
|
+
)
|
|
608
|
+
if (!res.ok) return null
|
|
609
|
+
const body = await res.json().catch(() => null)
|
|
610
|
+
if (!body) return null
|
|
611
|
+
// The read returns `version`; callers use `latest_version`. Tolerate both.
|
|
612
|
+
return {
|
|
613
|
+
...body,
|
|
614
|
+
latest_version: body.latest_version ?? body.version ?? null
|
|
615
|
+
}
|
|
616
|
+
} catch (err) {
|
|
617
|
+
// Node's `fetch` throws `TypeError: fetch failed` when nothing answered.
|
|
618
|
+
if (err instanceof TypeError && /fetch failed/i.test(err.message)) {
|
|
619
|
+
throw new BackendUnreachableError(this.origin, err)
|
|
620
|
+
}
|
|
621
|
+
return null
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* GET /dev/site/foundation-schema/{site}/{@scope}/{name}/{version} → the registered version of a
|
|
627
|
+
* foundation a site uses, as the backend holds it: its `schema` (section types with their
|
|
628
|
+
* `data:`), `info`, `data_schemas` (names), `module_url` and `css_url`. Read through the SITE,
|
|
629
|
+
* which is the licence: the caller must be able to read the site, and the site must use the
|
|
630
|
+
* foundation. Null on a 404 (not registered, or not this site's) and on any failure — a caller
|
|
631
|
+
* degrades, since what it is read for can be done without it, less well.
|
|
632
|
+
* @param {string} siteUuid - the site-content uuid
|
|
633
|
+
* @param {string} ref - the catalog ref, `@scope/name@version`
|
|
634
|
+
* @returns {Promise<object|null>}
|
|
635
|
+
*/
|
|
636
|
+
async readRegisteredFoundation(siteUuid, ref) {
|
|
637
|
+
const m = /^(@[^/]+)\/([^@/]+)@(.+)$/.exec(String(ref || ''))
|
|
638
|
+
if (!siteUuid || !m) return null
|
|
639
|
+
try {
|
|
640
|
+
const res = await this.request(
|
|
641
|
+
`/dev/site/foundation-schema/${encodeURIComponent(siteUuid)}/${m[1]}/${encodeURIComponent(m[2])}/${encodeURIComponent(m[3])}`
|
|
642
|
+
)
|
|
643
|
+
if (!res.ok) return null
|
|
644
|
+
const body = await res.json().catch(() => null)
|
|
645
|
+
return body && typeof body === 'object' && body.schema && typeof body.schema === 'object' ? body : null
|
|
646
|
+
} catch {
|
|
647
|
+
return null
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── Orgs ──────────────────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
/** GET /dev/orgs → { account_handle, orgs[] }. */
|
|
654
|
+
async fetchOrgs() {
|
|
655
|
+
return fetchOrgsImpl({ apiBase: this.origin, token: await this.token(), fetchImpl: this._fetch })
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** POST /dev/orgs { handle } → { handle, uuid, is_primary }. Throws with the server's detail on 409/422. */
|
|
659
|
+
async createOrg(handle) {
|
|
660
|
+
return createOrgImpl({
|
|
661
|
+
apiBase: this.origin,
|
|
662
|
+
token: await this.token(),
|
|
663
|
+
handle
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Site sync (push / pull) ─────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* POST /dev/site — CREATE an EMPTY site and return its site-content uuid.
|
|
671
|
+
*
|
|
672
|
+
* The uuid exists before any content or asset does, which is the whole point:
|
|
673
|
+
* uploaded bytes are metered against an owning entity and freed by deleting it,
|
|
674
|
+
* so an upload with no site to charge is billed and can never be reclaimed.
|
|
675
|
+
* Creating the site first is what makes a failed first publish leave a clearable
|
|
676
|
+
* empty site instead of unfreeable bytes.
|
|
677
|
+
*
|
|
678
|
+
* A thin re-projection of the same op the app's blank-site create uses, so the
|
|
679
|
+
* two lanes cannot drift. Adoption of `foundation` is best-effort — an
|
|
680
|
+
* unreleased ref leaves the resolved ref null and the site still exists — but
|
|
681
|
+
* the field itself is required and cannot be blank.
|
|
682
|
+
*
|
|
683
|
+
* NOT idempotent: two calls mint two sites (a name is not a unique key, by
|
|
684
|
+
* design). Callers must guard on the site's uuid for this backend (sync.json) and
|
|
685
|
+
* write the result back immediately — see `ensureSiteExists`.
|
|
686
|
+
*
|
|
687
|
+
* The owner is the workspace the client names (`setWorkspace`) — the create is where
|
|
688
|
+
* that choice is made, once.
|
|
689
|
+
*
|
|
690
|
+
* @param {{ name: string, foundation: string }} opts
|
|
691
|
+
* @returns {Promise<Response>} `{ site_content_uuid }`
|
|
692
|
+
*/
|
|
693
|
+
async createSite({ name, foundation } = {}) {
|
|
694
|
+
return this.request('/dev/site', {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
// Both fields are REQUIRED: `info.name` is `required: true` on the model
|
|
697
|
+
// (and is a plain string — an identity label, never a `{lang: …}` map, since
|
|
698
|
+
// a localized value can vanish under locale projection and a name must always
|
|
699
|
+
// render), and a site must resolve to a foundation. Adoption of the ref is
|
|
700
|
+
// best-effort on the backend, but the field itself cannot be blank — so send
|
|
701
|
+
// whatever the site declares and let the backend judge it.
|
|
702
|
+
body: JSON.stringify({ name, foundation })
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
|
|
707
|
+
async createSiteContent(buffer) {
|
|
708
|
+
return this.request('/dev/site/content', {
|
|
709
|
+
method: 'POST',
|
|
710
|
+
body: buffer,
|
|
711
|
+
query: PUSH_QUERY
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
|
|
716
|
+
async updateSiteContent(uuid, buffer) {
|
|
717
|
+
return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
|
|
718
|
+
method: 'POST',
|
|
719
|
+
body: buffer,
|
|
720
|
+
query: PUSH_QUERY
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
|
|
725
|
+
async pushFolder(uuid, buffer) {
|
|
726
|
+
return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
|
|
727
|
+
method: 'POST',
|
|
728
|
+
body: buffer,
|
|
729
|
+
query: PUSH_QUERY
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* GET /dev/site/content/pull/{uuid} — the content lane document. Pass the
|
|
735
|
+
* last-seen ETag (opaque) to make it conditional: a match returns 304 (empty body).
|
|
736
|
+
*/
|
|
737
|
+
async pullSiteContent(uuid, { etag } = {}) {
|
|
738
|
+
return this.request(`/dev/site/content/pull/${encodeURIComponent(uuid)}`, {
|
|
739
|
+
headers: etag ? { 'If-None-Match': etag } : {}
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** GET /dev/site/folder/pull/{uuid} — the folder lane (folder + record documents). */
|
|
744
|
+
async pullFolder(uuid, { etag } = {}) {
|
|
745
|
+
return this.request(`/dev/site/folder/pull/${encodeURIComponent(uuid)}`, {
|
|
746
|
+
headers: etag ? { 'If-None-Match': etag } : {}
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ── Delivery: deploy + site publish ─────────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* POST /dev/deploy — dumb, file-built delivery. Body is the deploy payload (the
|
|
754
|
+
* runtime-init JSON `build-site-data.js` produces — foundation, theme,
|
|
755
|
+
* languages, locales, optional dataFiles/searchFiles) plus an optional
|
|
756
|
+
* `site_uuid`. First deploy of a never-synced site omits it → the backend mints
|
|
757
|
+
* a uuid and returns it for write-back to deploy.yml; later deploys resend it so
|
|
758
|
+
* the site's published URL stays stable. Returns the raw Response so the caller
|
|
759
|
+
* messages its own errors and reads `{ site_uuid, url, locales }` on 200.
|
|
760
|
+
* @param {object} payload - the deploy payload (universal currency)
|
|
761
|
+
* @param {object} [opts]
|
|
762
|
+
* @param {string} [opts.siteUuid] - a previously-minted delivery uuid
|
|
763
|
+
* @returns {Promise<Response>}
|
|
764
|
+
*/
|
|
765
|
+
async deploy(payload, { siteUuid } = {}) {
|
|
766
|
+
const body = siteUuid ? { ...payload, site_uuid: siteUuid } : payload
|
|
767
|
+
return this.request('/dev/deploy', {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
body: JSON.stringify(body)
|
|
770
|
+
})
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
|
|
775
|
+
* backend state live; it does NOT push local files). `{uuid}` is the site-content
|
|
776
|
+
* uuid on this backend (sync.json); a never-synced site 404s (sync first, or use deploy).
|
|
777
|
+
* Languages, when present, go in the body; absent → no body. Returns the raw
|
|
778
|
+
* Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
|
|
779
|
+
*
|
|
780
|
+
* ⛔ Sends NOTHING about a runtime. A `?runtime=<version>` param rode here until
|
|
781
|
+
* 2026-08-22, carrying a `site.yml::runtime` pin — a vestigial prop [Diego]: no
|
|
782
|
+
* template ever set it, no public doc described it, and the backend may already
|
|
783
|
+
* have been ignoring it. A site is codeless and has no basis for naming a
|
|
784
|
+
* runtime; the binding party is the FOUNDATION, whose floor travels as
|
|
785
|
+
* `info.runtime` at register. Do not reintroduce the param.
|
|
786
|
+
* @param {string} uuid - the site-content uuid
|
|
787
|
+
* @param {object} [opts]
|
|
788
|
+
* @param {string[]} [opts.languages]
|
|
789
|
+
* @returns {Promise<Response>}
|
|
790
|
+
*/
|
|
791
|
+
async publishSite(uuid, { languages, confirm, unattended = false } = {}) {
|
|
792
|
+
// `confirm`: the token of a confirm the backend offered on a refused publish, sent back as it
|
|
793
|
+
// came, to settle a change to the site's plan and publish in one call. `unattended`: no one
|
|
794
|
+
// answered it — `--yes` did, which the backend settles for a reduction and nothing else.
|
|
795
|
+
const query = confirm
|
|
796
|
+
? `?confirm=${encodeURIComponent(confirm)}${unattended ? '&unattended=true' : ''}`
|
|
797
|
+
: ''
|
|
798
|
+
return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}${query}`, {
|
|
799
|
+
method: 'POST',
|
|
800
|
+
...(languages ? { body: JSON.stringify({ languages }) } : {})
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* POST /dev/site/unpublish/{uuid} — drop the published-folder gate so the host
|
|
806
|
+
* stops serving the site's dynamic content. Returns the raw Response ({ was_published }).
|
|
807
|
+
* @param {string} uuid - the site-content uuid
|
|
808
|
+
* @returns {Promise<Response>}
|
|
809
|
+
*/
|
|
810
|
+
async unpublishSite(uuid) {
|
|
811
|
+
return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, {
|
|
812
|
+
method: 'POST'
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* GET /dev/site — one page of the sites in the workspace the client names
|
|
818
|
+
* (`setWorkspace`; none ⇒ personal): `{ sites: [{ uuid, name?, updated_at,
|
|
819
|
+
* deployment?: { status, published_url, … } }] }`. A page shorter than `limit` is
|
|
820
|
+
* the last. `deployment` is absent for a site never published.
|
|
821
|
+
*
|
|
822
|
+
* @param {{ limit?: number, offset?: number }} [opts]
|
|
823
|
+
* @returns {Promise<Response>}
|
|
824
|
+
*/
|
|
825
|
+
async listSites({ limit = 100, offset = 0 } = {}) {
|
|
826
|
+
return this.request('/dev/site', { query: { limit, offset } })
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* DELETE /dev/site/{uuid} — delete a site, finally: no trash, no restore. Only an
|
|
831
|
+
* admin of the site may. Refused `409` with `blockers: [{ resource, detail }]` while
|
|
832
|
+
* anything is still active — `published` among them, which `unpublishSite` clears.
|
|
833
|
+
*
|
|
834
|
+
* @param {string} uuid - the site-content uuid
|
|
835
|
+
* @returns {Promise<Response>}
|
|
836
|
+
*/
|
|
837
|
+
async deleteSite(uuid) {
|
|
838
|
+
return this.request(`/dev/site/${encodeURIComponent(uuid)}`, { method: 'DELETE' })
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
|
|
843
|
+
* shipped backend-side — collab backend↔framework):
|
|
844
|
+
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
845
|
+
* `draft_dirty` = never-published, or the synced draft changed since the last
|
|
846
|
+
* publish ("pushed but not published").
|
|
847
|
+
*
|
|
848
|
+
* ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
|
|
849
|
+
* it yet** (shipped 2026-08-29; documented here so it is not lost twice):
|
|
850
|
+
*
|
|
851
|
+
* last_published_url · last_published_foundation · last_published_extensions
|
|
852
|
+
* last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
|
|
853
|
+
*
|
|
854
|
+
* Three things about it that a reader will otherwise get wrong:
|
|
855
|
+
*
|
|
856
|
+
* ⛔ `runtime_resolution` is `resolved` or `pinned:<reason>` (`operator` / `unknown_floor` /
|
|
857
|
+
* `no_foundations`). **A pin is a first-class answer, not a failure** — most sites are pinned
|
|
858
|
+
* at any moment, and an "old" runtime still satisfies the site's floor. Never surface
|
|
859
|
+
* `pinned:*` as an error state.
|
|
860
|
+
* ⛔ `extensions` is there because a site's code surface is the primary foundation **plus N
|
|
861
|
+
* extensions**; reading the primary alone describes a site nobody has.
|
|
862
|
+
* ⛔ `last_*` is deliberate on every one. `unpublish` LEAVES the URL populated (the static site
|
|
863
|
+
* may still be reachable), so beside `published: bool` a bare `published_url` would read as a
|
|
864
|
+
* liveness claim and be wrong exactly when it matters. And **nothing back-fills** — a site
|
|
865
|
+
* published before this reports them absent, which means "published before we recorded it",
|
|
866
|
+
* never "has no foundation".
|
|
867
|
+
*
|
|
868
|
+
* The path is VERB-FIRST (`status/{uuid}`)
|
|
869
|
+
* to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
|
|
870
|
+
* not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
|
|
871
|
+
* 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
|
|
872
|
+
* @param {string} uuid - the site-content uuid
|
|
873
|
+
* @returns {Promise<object|null>}
|
|
874
|
+
*/
|
|
875
|
+
/**
|
|
876
|
+
* Whether the backend holds the site `uuid` names — `live`, `gone` or `unknown`, kept
|
|
877
|
+
* apart — for a script that must decide, before any push, whether a binding still
|
|
878
|
+
* points at a site (`uniweb status --remote --json`, `remote.site_state`).
|
|
879
|
+
*
|
|
880
|
+
* ⛔ `gone` ONLY ON THE BACKEND'S OWN WORD ABOUT THIS SITE: a `404` whose problem names
|
|
881
|
+
* it — `kind: "site"`, `key: <uuid>`. A push from an unbound copy CREATES a site, so a
|
|
882
|
+
* binding dropped because its site was merely unreadable leaves a second copy beside
|
|
883
|
+
* the first. Folding `unknown` into `gone` would be worse than no answer.
|
|
884
|
+
*
|
|
885
|
+
* live the site's status came back, or the site was refused as belonging to
|
|
886
|
+
* another workspace (`409 wrong_workspace` names where it is: it exists)
|
|
887
|
+
* gone a `404` naming this site
|
|
888
|
+
* unknown anything else — no answer, a credential or an id refused, a `404` that
|
|
889
|
+
* names something else (a backend too old for the route)
|
|
890
|
+
*
|
|
891
|
+
* *Measured 2026-10-06 against a local backend — its wire, its to change:* `200` with
|
|
892
|
+
* the status; `409 wrong_workspace` naming the site's workspace; `404 {kind: "site",
|
|
893
|
+
* key}`; `400` for a malformed id; `401` for a bad bearer.
|
|
894
|
+
*
|
|
895
|
+
* @param {string} uuid
|
|
896
|
+
* @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, detail?: string|null }>}
|
|
897
|
+
*/
|
|
898
|
+
async siteState(uuid) {
|
|
899
|
+
let res
|
|
900
|
+
try {
|
|
901
|
+
res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
|
|
902
|
+
} catch (err) {
|
|
903
|
+
if (err instanceof WorkspaceMismatchError) {
|
|
904
|
+
return { state: 'live', status: 409, workspace: err.answer, detail: err.message }
|
|
905
|
+
}
|
|
906
|
+
return { state: 'unknown', status: null, detail: err?.message || String(err) }
|
|
907
|
+
}
|
|
908
|
+
const body = await res.text().catch(() => '')
|
|
909
|
+
let parsed = null
|
|
910
|
+
try {
|
|
911
|
+
parsed = body ? JSON.parse(body) : null
|
|
912
|
+
} catch {
|
|
913
|
+
parsed = null
|
|
914
|
+
}
|
|
915
|
+
if (res.ok) return { state: 'live', status: res.status, ...(parsed ? { site: parsed } : {}) }
|
|
916
|
+
if (res.status === 409 && parsed?.reason === 'wrong_workspace') {
|
|
917
|
+
const w = parsed.workspace || {}
|
|
918
|
+
return {
|
|
919
|
+
state: 'live',
|
|
920
|
+
status: 409,
|
|
921
|
+
workspace: w.handle ? workspaceHandle(w.handle) : w.unit_uuid || null,
|
|
922
|
+
detail: parsed.detail || null
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const names = (v) => String(v ?? '').toLowerCase() === String(uuid).toLowerCase()
|
|
926
|
+
if (res.status === 404 && parsed?.kind === 'site' && names(parsed.key)) {
|
|
927
|
+
return { state: 'gone', status: 404, detail: parsed.detail || null }
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
state: 'unknown',
|
|
931
|
+
status: res.status,
|
|
932
|
+
detail: parsed?.detail || parsed?.title || (body ? body.slice(0, 200) : null)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async siteStatus(uuid) {
|
|
937
|
+
try {
|
|
938
|
+
const res = await this.request(
|
|
939
|
+
`/dev/site/status/${encodeURIComponent(uuid)}`
|
|
940
|
+
)
|
|
941
|
+
if (res.status === 409) {
|
|
942
|
+
// A refusal about the workspace — e.g. a deployment with one workspace, naming
|
|
943
|
+
// it — is an answer to report, not an absent status.
|
|
944
|
+
const refused = new Error((await refusalDetail(res)) || 'HTTP 409 Conflict')
|
|
945
|
+
refused.status = 409
|
|
946
|
+
throw refused
|
|
947
|
+
}
|
|
948
|
+
return res.ok ? await res.json().catch(() => null) : null
|
|
949
|
+
} catch (err) {
|
|
950
|
+
// A mismatch or a refusal is an answer, not an absent status.
|
|
951
|
+
if (err instanceof WorkspaceMismatchError || err?.status === 409) throw err
|
|
952
|
+
return null
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Deliver a site's processed assets (plan → PUT-per-file) to the backend's
|
|
958
|
+
* content-addressed store. Thin pass-through to utils/asset-upload.js with this
|
|
959
|
+
* client's origin + token; returns the `localUrl → { id, ext, serveUrl }` rewrite
|
|
960
|
+
* map the deploy step reads `serveUrl` from verbatim (never composing one).
|
|
961
|
+
* @param {object} opts - { distDir, files?, onProgress? }
|
|
962
|
+
*/
|
|
963
|
+
async uploadSiteAssets(opts) {
|
|
964
|
+
return uploadSiteAssets({
|
|
965
|
+
apiBase: this.origin,
|
|
966
|
+
token: await this.token(),
|
|
967
|
+
...opts
|
|
968
|
+
})
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** `@scope/name` → /dev/registry/data-schemas/{scope}/{name}; a bare name → …/{name}. */
|
|
974
|
+
export function dataSchemaPath(modelName) {
|
|
975
|
+
const m = /^@([^/]+)\/(.+)$/.exec(modelName)
|
|
976
|
+
if (m)
|
|
977
|
+
return `/dev/registry/data-schemas/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
|
|
978
|
+
return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The shared push query: last-push-wins. ⛔ No `as_org` — the workspace rides the
|
|
983
|
+
* `x-uniweb-workspace` header on every site request (`BackendClient.setWorkspace`),
|
|
984
|
+
* since 2026-09-23.
|
|
985
|
+
*/
|
|
986
|
+
const PUSH_QUERY = { collision: 'force' }
|