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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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' }