uniweb 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.29.8",
45
- "@uniweb/schemas": "^0.3.3",
46
- "@uniweb/semantic-parser": "^1.4.1",
44
+ "@uniweb/content-writer": "^0.3.4",
47
45
  "@uniweb/kit": "^0.19.4",
46
+ "@uniweb/core": "^0.29.8",
48
47
  "@uniweb/runtime": "^0.27.4",
49
- "@uniweb/content-writer": "^0.3.4"
48
+ "@uniweb/schemas": "^0.3.4",
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
+ "@uniweb/build": "^0.59.0",
52
53
  "@uniweb/content-reader": "^1.2.5",
53
- "@uniweb/semantic-parser": "^1.4.1",
54
- "@uniweb/build": "^0.58.0"
54
+ "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -2575,7 +2575,9 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2575
2575
  > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2576
2576
  > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2577
2577
  > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2578
- > ownership is settled, so nothing is asked.
2578
+ > ownership is settled, so nothing is asked. After the create, every command names the
2579
+ > recorded org for you; an `--org` that disagrees with where the backend keeps the site
2580
+ > **stops the command** rather than acting in another org.
2579
2581
  >
2580
2582
  > Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
2581
2583
  > If you get `Unknown flag`, read the suggestion — it is usually a near miss.
@@ -2630,10 +2632,9 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2630
2632
 
2631
2633
  | Field | Where used | Default | Purpose |
2632
2634
  |---|---|---|---|
2633
- | `scope` | `uniweb register` | derived from your login, then saved here | The org the foundation registers under (`@acme`, or `acme`): a bare `main.js` name `marketing` registers as `@acme/marketing`. `--scope @org` overrides it for one run. |
2634
2635
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2635
2636
 
2636
- **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`). ⛔ `uniweb.id` is no longer read: `uniweb register` refuses it and prints the `main.js` line to write instead.
2637
+ **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the org it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope @org` (else one of your orgs) and writes it into the name, and a `--scope` naming another org than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
2637
2638
 
2638
2639
  **Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
2639
2640
 
@@ -105,6 +105,141 @@ export function resolveBackendOrigin() {
105
105
  */
106
106
  export const DISCOVERY_DEFAULTS = {}
107
107
 
108
+ /**
109
+ * The header a request names its WORKSPACE with — the unit it works in.
110
+ *
111
+ * ⭐ `@<handle>`, the `@` required: a bare value is read as a unit's uuid — the spelling
112
+ * for a workspace with no handle — so the two can never be mistaken for each other.
113
+ * Every `/dev` route accepts it; the client names it on the site routes, the ones that
114
+ * work in a workspace (`namesWorkspace`). ⛔ It replaced `?as_org=` (2026-09-23), which
115
+ * only routes that declared it accepted — the rest answered `400`.
116
+ */
117
+ export const WORKSPACE_HEADER = 'x-uniweb-workspace'
118
+
119
+ /**
120
+ * Whether a request works IN a workspace — a site route, `/dev/site…`.
121
+ *
122
+ * Only those name one. Every `/dev` route accepts the header, but registration reads
123
+ * none (the `@scope` in the name decides, membership-gated), `/dev/assets` reads none
124
+ * (a charge lands on the entity's own workspace), and auth, orgs and config are about
125
+ * the account. Naming the site's workspace there would be harmless and meaningless —
126
+ * and a workspace that is refused (`400`/`403`) would then fail a registration that
127
+ * never involved it.
128
+ *
129
+ * @param {string} path - leading-slash path
130
+ * @returns {boolean}
131
+ */
132
+ export function namesWorkspace(path) {
133
+ return /^\/dev\/site(\/|$)/.test(String(path))
134
+ }
135
+
136
+ /**
137
+ * A workspace as it may be written — `@acme`, `acme`, `@acme/…` — as the header's
138
+ * `@acme`; null for none (the personal workspace, which a request names by omission).
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {string|null}
142
+ */
143
+ export function workspaceHandle(value) {
144
+ const h =
145
+ typeof value === 'string' ? value.trim().replace(/^@/, '').replace(/\/.*$/, '') : ''
146
+ return h ? `@${h}` : null
147
+ }
148
+
149
+ const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
150
+
151
+ /**
152
+ * A workspace as the header names it — `@handle`, or a unit's bare uuid when the unit
153
+ * has no handle — or null for none.
154
+ *
155
+ * ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
156
+ * handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
157
+ * So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
158
+ * record through `readSiteWorkspace` — and a bare uuid here is a unit.
159
+ *
160
+ * @param {unknown} value
161
+ * @returns {string|null}
162
+ */
163
+ export function workspaceHeader(value) {
164
+ if (typeof value !== 'string' || !value.trim()) return null
165
+ const v = value.trim()
166
+ return !v.startsWith('@') && UNIT_UUID.test(v) ? v : workspaceHandle(v)
167
+ }
168
+
169
+ /**
170
+ * The request named its workspace EXPLICITLY — `--org`, `--personal`, a choice made at
171
+ * create — and the backend works on the site from another.
172
+ *
173
+ * ⛔ Stopping is the point. It is the mismatch check [Diego, 2026-09-22: "there is no org
174
+ * mismatch when the site is set for an org and the user tries a different one"], and it
175
+ * is the backend's `409` that decides it, never a handle comparison here: a site in
176
+ * `@acme/labs` is legitimately worked on from `@acme`. Adopting the answer would override
177
+ * a choice the user made.
178
+ */
179
+ export class WorkspaceMismatchError extends Error {
180
+ /**
181
+ * @param {{ named: string|null, answer: string|null }} p - the workspace the request
182
+ * named, and the one the backend named (`@handle`, null for personal)
183
+ */
184
+ constructor({ named, answer }) {
185
+ const where = (w) =>
186
+ !w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
187
+ const flag = named ? '--org' : '--personal'
188
+ super(
189
+ `The backend works on this site from ${where(answer)}, not ${where(named)} ` +
190
+ `(${named ? `--org ${named}` : flag}). ` +
191
+ // `--org` takes a handle; a unit without one is reached by naming nothing.
192
+ (answer?.startsWith('@') ? `Pass --org ${answer}, or drop ${flag}.` : `Drop ${flag}.`)
193
+ )
194
+ this.name = 'WorkspaceMismatchError'
195
+ this.status = 409
196
+ this.named = named
197
+ this.answer = answer
198
+ }
199
+ }
200
+
201
+ /**
202
+ * The line to print when a request THREW rather than answered.
203
+ *
204
+ * A `WorkspaceMismatchError` is the backend's answer, not a transport failure, so it is
205
+ * printed as itself: "Could not reach the backend" in front of it would send the user
206
+ * to check their network over a choice they made on the command line.
207
+ *
208
+ * @param {unknown} err
209
+ * @param {string} origin
210
+ * @returns {string}
211
+ */
212
+ export function describeRequestError(err, origin) {
213
+ if (err instanceof WorkspaceMismatchError) return err.message
214
+ return `Could not reach the backend at ${origin}: ${err?.message ?? err}`
215
+ }
216
+
217
+ /**
218
+ * The sentence a refusal carries — a problem document's `detail`, else its `title`, else
219
+ * the start of a prose body — or null. Consumes the body. For the surfaces that would
220
+ * otherwise print a status line alone, so a refusal the backend explains is shown as it
221
+ * is: a deployment with one workspace answers a request naming another with a plain
222
+ * `409 Conflict` whose `detail` names the one there is.
223
+ *
224
+ * @param {Response} res
225
+ * @returns {Promise<string|null>}
226
+ */
227
+ export async function refusalDetail(res) {
228
+ let body = ''
229
+ try {
230
+ body = await res.text()
231
+ } catch {
232
+ return null
233
+ }
234
+ try {
235
+ const problem = JSON.parse(body)
236
+ const line = problem?.detail || problem?.title
237
+ return typeof line === 'string' && line ? line : null
238
+ } catch {
239
+ return body ? body.slice(0, 300) : null
240
+ }
241
+ }
242
+
108
243
  export class BackendClient {
109
244
  /**
110
245
  * @param {object} [opts]
@@ -134,6 +269,35 @@ export class BackendClient {
134
269
  this._command = command
135
270
  this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
136
271
  this._discovery = null
272
+ this._workspace = null
273
+ this._workspaceExplicit = false
274
+ this._onWorkspaceAdopted = null
275
+ }
276
+
277
+ /** The workspace this client's requests name — `@handle`, or null (none named). */
278
+ get workspace() {
279
+ return this._workspace
280
+ }
281
+
282
+ /**
283
+ * Name the workspace every request from here on works in (`x-uniweb-workspace`).
284
+ *
285
+ * @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null to
286
+ * name none (`workspaceHeader`)
287
+ * @param {object} [opts]
288
+ * @param {boolean} [opts.explicit=false] - the user named it (`--org`, `--personal`,
289
+ * the create picker). A `409 wrong_workspace` then STOPS the command
290
+ * (`WorkspaceMismatchError`) instead of adopting the backend's answer.
291
+ * @param {(handle: string|null) => void|Promise<void>} [opts.onAdopted] - called when a
292
+ * request that named nothing, or a stale workspace, adopts the one the backend named;
293
+ * the caller records it (sync.json) and says so
294
+ * @returns {this}
295
+ */
296
+ setWorkspace(value, { explicit = false, onAdopted = null } = {}) {
297
+ this._workspace = workspaceHeader(value)
298
+ this._workspaceExplicit = Boolean(explicit)
299
+ this._onWorkspaceAdopted = onAdopted || null
300
+ return this
137
301
  }
138
302
 
139
303
  /**
@@ -168,7 +332,9 @@ export class BackendClient {
168
332
  * applies query params, and infers a content-type from the body when unset
169
333
  * (string → application/json, Buffer/Uint8Array → application/zip). Returns
170
334
  * the raw Response so callers branch on status themselves (409 resume,
171
- * 404 → null, 401/403 messaging, …).
335
+ * 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, which is
336
+ * answered here: adopted and retried once when the workspace was not named, and
337
+ * thrown as `WorkspaceMismatchError` when it was (see the body).
172
338
  *
173
339
  * @param {string} path - leading-slash path, e.g. '/dev/site/content'
174
340
  * @param {object} [opts]
@@ -179,10 +345,41 @@ export class BackendClient {
179
345
  * @param {boolean} [opts.auth=true]
180
346
  * @returns {Promise<Response>}
181
347
  */
182
- async request(
183
- path,
184
- { method = 'GET', body, headers = {}, query, auth = true } = {}
185
- ) {
348
+ async request(path, opts = {}) {
349
+ const res = await this._send(path, opts)
350
+ if (res?.status !== 409) return res
351
+
352
+ // ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request that
353
+ // names the wrong one for an existing site is refused with `409 wrong_workspace`,
354
+ // naming a workspace the caller CAN open that contains the site — its own unit, or a
355
+ // parent the caller belongs to. Two answers, and which one depends on the user:
356
+ // - they named it (`--org`, `--personal`) → stop and say so: the mismatch check;
357
+ // - they named nothing, or a stale one → adopt the backend's answer, let the caller
358
+ // record it, and retry ONCE.
359
+ // ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
360
+ const problem =
361
+ typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
362
+ if (problem?.reason !== 'wrong_workspace') return res
363
+ const where = problem.workspace || {}
364
+ // A unit with no handle is named by its uuid, bare — the header takes either.
365
+ const answer = where.handle
366
+ ? workspaceHandle(where.handle)
367
+ : typeof where.unit_uuid === 'string' && where.unit_uuid
368
+ ? where.unit_uuid
369
+ : null
370
+ // Naming what the backend names and still refused: nothing to adopt, or to report
371
+ // as a mismatch. The caller reads the 409 like any other.
372
+ if (answer === this._workspace) return res
373
+ if (this._workspaceExplicit) {
374
+ throw new WorkspaceMismatchError({ named: this._workspace, answer })
375
+ }
376
+ this._workspace = answer
377
+ if (this._onWorkspaceAdopted) await this._onWorkspaceAdopted(answer)
378
+ return this._send(path, opts)
379
+ }
380
+
381
+ /** One request, as `request` documents it — naming the client's workspace on a site route. */
382
+ async _send(path, { method = 'GET', body, headers = {}, query, auth = true } = {}) {
186
383
  const url = new URL(path, this.origin)
187
384
  if (query) {
188
385
  for (const [k, v] of Object.entries(query)) {
@@ -191,6 +388,7 @@ export class BackendClient {
191
388
  }
192
389
  const h = { ...headers }
193
390
  if (auth) h.Authorization = `Bearer ${await this.token()}`
391
+ if (this._workspace && namesWorkspace(path)) h[WORKSPACE_HEADER] = this._workspace
194
392
  if (body != null && h['Content-Type'] == null) {
195
393
  if (typeof body === 'string') h['Content-Type'] = 'application/json'
196
394
  else if (body instanceof Uint8Array || Buffer.isBuffer(body))
@@ -405,10 +603,13 @@ export class BackendClient {
405
603
  * design). Callers must guard on the site's uuid for this backend (sync.json) and
406
604
  * write the result back immediately — see `ensureSiteExists`.
407
605
  *
408
- * @param {{ name: string, foundation: string, asOrg?: string|null }} opts
606
+ * The owner is the workspace the client names (`setWorkspace`) — the create is where
607
+ * that choice is made, once.
608
+ *
609
+ * @param {{ name: string, foundation: string }} opts
409
610
  * @returns {Promise<Response>} `{ site_content_uuid }`
410
611
  */
411
- async createSite({ name, foundation, asOrg } = {}) {
612
+ async createSite({ name, foundation } = {}) {
412
613
  return this.request('/dev/site', {
413
614
  method: 'POST',
414
615
  // Both fields are REQUIRED: `info.name` is `required: true` on the model
@@ -417,35 +618,34 @@ export class BackendClient {
417
618
  // render), and a site must resolve to a foundation. Adoption of the ref is
418
619
  // best-effort on the backend, but the field itself cannot be blank — so send
419
620
  // whatever the site declares and let the backend judge it.
420
- body: JSON.stringify({ name, foundation }),
421
- query: { as_org: asOrg }
621
+ body: JSON.stringify({ name, foundation })
422
622
  })
423
623
  }
424
624
 
425
625
  /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
426
- async createSiteContent(buffer, { asOrg } = {}) {
626
+ async createSiteContent(buffer) {
427
627
  return this.request('/dev/site/content', {
428
628
  method: 'POST',
429
629
  body: buffer,
430
- query: pushQuery(asOrg)
630
+ query: PUSH_QUERY
431
631
  })
432
632
  }
433
633
 
434
634
  /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
435
- async updateSiteContent(uuid, buffer, { asOrg } = {}) {
635
+ async updateSiteContent(uuid, buffer) {
436
636
  return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
437
637
  method: 'POST',
438
638
  body: buffer,
439
- query: pushQuery(asOrg)
639
+ query: PUSH_QUERY
440
640
  })
441
641
  }
442
642
 
443
643
  /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
444
- async pushFolder(uuid, buffer, { asOrg } = {}) {
644
+ async pushFolder(uuid, buffer) {
445
645
  return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
446
646
  method: 'POST',
447
647
  body: buffer,
448
- query: pushQuery(asOrg)
648
+ query: PUSH_QUERY
449
649
  })
450
650
  }
451
651
 
@@ -565,8 +765,17 @@ export class BackendClient {
565
765
  const res = await this.request(
566
766
  `/dev/site/status/${encodeURIComponent(uuid)}`
567
767
  )
768
+ if (res.status === 409) {
769
+ // A refusal about the workspace — e.g. a deployment with one workspace, naming
770
+ // it — is an answer to report, not an absent status.
771
+ const refused = new Error((await refusalDetail(res)) || 'HTTP 409 Conflict')
772
+ refused.status = 409
773
+ throw refused
774
+ }
568
775
  return res.ok ? await res.json().catch(() => null) : null
569
- } catch {
776
+ } catch (err) {
777
+ // A mismatch or a refusal is an answer, not an absent status.
778
+ if (err instanceof WorkspaceMismatchError || err?.status === 409) throw err
570
779
  return null
571
780
  }
572
781
  }
@@ -596,7 +805,9 @@ export function dataSchemaPath(modelName) {
596
805
  return `/dev/registry/data-schemas/${encodeURIComponent(modelName)}`
597
806
  }
598
807
 
599
- /** The shared push query: last-push-wins, plus an optional acting-org. */
600
- function pushQuery(asOrg) {
601
- return { collision: 'force', ...(asOrg ? { as_org: asOrg } : {}) }
602
- }
808
+ /**
809
+ * The shared push query: last-push-wins. ⛔ No `as_org` — the workspace rides the
810
+ * `x-uniweb-workspace` header on every site request (`BackendClient.setWorkspace`),
811
+ * since 2026-09-23.
812
+ */
813
+ const PUSH_QUERY = { collision: 'force' }
@@ -44,7 +44,6 @@ import {
44
44
  } from '@uniweb/build'
45
45
  import { computeFoundationDigest } from '../utils/code-upload.js'
46
46
  import { isNonInteractive } from '../utils/interactive.js'
47
- import { publishScope } from '../utils/registry-orgs.js'
48
47
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
49
48
 
50
49
  /**
@@ -119,18 +118,20 @@ export function resolveLocalExtensions(siteDir, siteYml) {
119
118
  /**
120
119
  * The foundation's scoped catalog name (`@org/name`): its name — `main.js`'s
121
120
  * `name`, else package.json's, the one rule the build reads for the schema
122
- * `register` submits (`readFoundationName`) — scoped by `uniweb.scope` unless
123
- * already scoped. Null when no registrable scoped name can be formed; the caller
121
+ * `register` submits (`readFoundationName`). ⭐ The scope is part of that name
122
+ * (2026-09-22), so a scoped name IS the catalog name. Null otherwise; the caller
124
123
  * then treats the foundation as unreleased, and `register` — which it runs to
125
- * release — is where a missing scope is derived and a missing name is asked for.
124
+ * release — is where a missing scope is chosen and written into the name, and a
125
+ * missing name is asked for.
126
126
  *
127
127
  * ⛔ A NAME THAT CANNOT REGISTER IS NULL, not looked up. `src` and `foundation`
128
128
  * name a folder (`checkFoundationName`); looking one up would find some other
129
129
  * project's `@org/src` — the very collision refusing them prevents.
130
130
  *
131
- * ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
132
- * may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
133
- * bare one looked the foundation up as `acme/src` and pinned the site to that.
131
+ * ⛔ A BARE NAME IS NULL too: it has not registered, since `register` writes the scope
132
+ * it registers under into the name. Until 2026-09-22 this joined a bare name with
133
+ * `package.json::uniweb.scope`; that key is refused now (`readFoundationName` throws,
134
+ * so a leftover reads as unreleased here and `register` says what to write instead).
134
135
  * *(This read package.json alone until 2026-09-21 — `uniweb.id`, else `name` — and
135
136
  * so named a foundation differently from the build whenever `main.js` named it.)*
136
137
  *
@@ -145,9 +146,7 @@ export async function foundationScopedName(dir) {
145
146
  return null
146
147
  }
147
148
  if (checkFoundationName(name)) return null
148
- if (name.startsWith('@')) return name
149
- const scope = publishScope(readPkgField(dir, 'uniweb')?.scope)
150
- return scope ? `${scope}/${name}` : null
149
+ return name.startsWith('@') ? name : null
151
150
  }
152
151
 
153
152
  function readPkgField(dir, field) {
@@ -303,9 +302,9 @@ async function bringLocalCodeAlong({
303
302
  // exists to avoid.
304
303
  //
305
304
  // ⚠️ It is null for a foundation that has NEVER been registered and carries no
306
- // scope (a fresh scaffold), because the scope is what `register` writes back
307
- // (`writePkgScope`) — and for one with no name of its own (`src`), which
308
- // `register` asks for. So the preview shows the authored value there, and the
305
+ // scope (a fresh scaffold), because the scope is what `register` writes into its
306
+ // name (`settleFoundationScope`) — and for one with no name of its own (`src`),
307
+ // which `register` asks for. So the preview shows the authored value there, and the
309
308
  // first real push — which releases, and so acquires both — sends the pinned ref
310
309
  // instead. That gap is unavoidable offline: before the first release there is
311
310
  // no registered name to name.