uniweb 0.64.1 → 0.66.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.64.1",
3
+ "version": "0.66.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/kit": "^0.19.4",
44
45
  "@uniweb/runtime": "^0.27.4",
45
- "@uniweb/schemas": "^0.4.0",
46
+ "@uniweb/content-writer": "^0.3.4",
47
+ "@uniweb/schemas": "^0.5.0",
46
48
  "@uniweb/core": "^0.29.8",
47
- "@uniweb/kit": "^0.19.4",
48
- "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/content-writer": "^0.3.4"
49
+ "@uniweb/semantic-parser": "^1.4.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/semantic-parser": "^1.4.1",
53
52
  "@uniweb/content-reader": "^1.2.5",
54
- "@uniweb/build": "^0.59.1"
53
+ "@uniweb/semantic-parser": "^1.4.1",
54
+ "@uniweb/build": "^0.60.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -1870,7 +1870,7 @@ fetch:
1870
1870
 
1871
1871
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useWholeRecord`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1872
1872
 
1873
- **Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. Warns by default; `--strict` for a non-zero CI exit. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred.
1873
+ **Validate before shipping.** `uniweb validate` checks file-based data against your declared schemas — missing required fields, type/enum/format mismatches, nested fields. A violation fails it (`--lax` only reports); it checks every record file in `records/`, and `push` / `publish` refuse the same findings before sending anything. Distinct from `uniweb doctor` (project structure): `validate` checks your *data* against the schemas you *declared*. External queries (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred.
1874
1874
 
1875
1875
  ### Fetching from other sources (`fetcher:`)
1876
1876
 
@@ -2527,9 +2527,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
2527
2527
 
2528
2528
  uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
2529
2529
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2530
- uniweb push --org @acme # First push/publish of a site: who owns it (see below)
2530
+ uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
2531
2531
  uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
2532
- uniweb login / logout # One backend at a time: log in (--backend <url>) or out
2532
+ uniweb login / logout # One backend, one workspace at a time: log in (--backend, --org) or out
2533
2533
  uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
2534
2534
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2535
2535
 
@@ -2539,7 +2539,7 @@ uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
2539
2539
 
2540
2540
  uniweb -v # Installed CLI version — and whether a newer one exists
2541
2541
  uniweb doctor # Diagnose project configuration (--fix to auto-repair)
2542
- uniweb validate # Check file-based data against declared schemas (--strict for CI)
2542
+ uniweb validate # Check file-based data against declared schemas (fails on a violation; --lax)
2543
2543
  npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
2544
2544
  # bare `uniweb update` aligns to the CLI you ALREADY have
2545
2545
  uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
@@ -2573,24 +2573,24 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2573
2573
 
2574
2574
  **Content authors work visually in the Uniweb App.** They compose the same extended markdown and set the same component params you defined, through a visual editor — never touching code, git, or the CLI. They see exactly the section types your foundation offers and exactly the knobs each one exposes, because **every `meta.js` is registered as the foundation's schema** when you publish. Your `meta.js` is the app's UI (see *meta.js* in Part 4).
2575
2575
 
2576
- > ⚠️ **The FIRST push or publish of a site asks who owns it — and refuses if it cannot ask.**
2577
- > That create decides which organization owns the site (and whose storage its assets are billed
2578
- > to), it is the only moment the choice is made, and there is no CLI command to change it
2579
- > afterwards. At a terminal you get a picker. **Without a terminal — CI, a script, or you, an
2580
- > agent — the command exits non-zero instead of choosing**, and `--yes` refuses too, because
2581
- > guessing an owner is not an answer. Name it and it never asks again:
2576
+ > ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
2577
+ > A workspace is your personal one or an organization's. A site a push or publish **creates** is
2578
+ > created — owned, and its storage billed — there, and there is no CLI command to move it
2579
+ > afterwards. A site the backend keeps in **another** workspace stops the command, which says how
2580
+ > to switch. With no organization, the login works in your personal workspace; with
2581
+ > organizations, the login asks, or you name it:
2582
2582
  >
2583
2583
  > ```bash
2584
- > uniweb publish --org @acme # an organization
2585
- > uniweb publish --personal # your personal account, deliberately
2584
+ > uniweb login --org @acme # work in an organization
2585
+ > uniweb login --personal # work in your personal workspace
2586
+ > uniweb push --org @acme # this command only
2586
2587
  > ```
2587
2588
  >
2588
- > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2589
- > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2590
- > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2591
- > ownership is settled, so nothing is asked. After the create, every command names the
2592
- > recorded org for you; an `--org` that disagrees with where the backend keeps the site
2593
- > **stops the command** rather than acting in another org.
2589
+ > Already logged in, `uniweb login --org @other` switches without logging in again. **Without a
2590
+ > terminal — you, an agent — a login with organizations refuses until one is named**, and so
2591
+ > does a command when none was. A process logged in with `UNIWEB_TOKEN` names its workspace with
2592
+ > `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
2593
+ > picking for them — a site created in the wrong one cannot be moved from here.
2594
2594
  >
2595
2595
  > Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
2596
2596
  > If you get `Unknown flag`, read the suggestion — it is usually a near miss.
@@ -2609,7 +2609,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2609
2609
 
2610
2610
  Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
2611
2611
 
2612
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2612
+ **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version` — `name` from the foundation's `main.js`. When a foundation powers a single site, **don't run `uniweb register` yourself** — `uniweb publish` from the site directory releases the local foundation to the catalog (when its code changed) and goes live in one step. A registered version is immutable, so when the foundation's code changed under a version already registered, `push` and `publish` release the change under the next version and write it into the foundation's `package.json` — commit that file (`--no-release` sends the content without the code). They stop when the registry holds a version newer than yours, released from another copy: pull that change first, or pass `--bump` to release yours above it. Register deliberately only when the foundation is a product meant for multiple sites; consuming sites then pin `foundation: '@org/name@1.2.3'`. **The catalog is private and access-segregated, not a public package registry** — people see only the foundations licensed to sites they own or edit. The *site* carries the license, and it rides along with site ownership when a developer hands a site to a client. Don't describe publishing as making a foundation publicly discoverable. Schemas can also be registered on their own from a schemas-only package (`@uniweb/schemas`, any `@org/schemas`, or a bare folder of `schemas/*.{yml,json,js}`) — that's how `@std` schemas are published. Auth via `uniweb login` (`uniweb login --backend <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
2613
2613
 
2614
2614
  ### Staying current
2615
2615
 
@@ -148,6 +148,15 @@ export function workspaceHandle(value) {
148
148
 
149
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
150
 
151
+ /**
152
+ * A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal
153
+ * workspace` — for null, and for a login's stored `personal`.
154
+ */
155
+ export function describeWorkspace(workspace) {
156
+ if (!workspace || workspace === 'personal') return 'your personal workspace'
157
+ return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
158
+ }
159
+
151
160
  /**
152
161
  * A workspace as the header names it — `@handle`, or a unit's bare uuid when the unit
153
162
  * has no handle — or null for none.
@@ -155,7 +164,7 @@ const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
155
164
  * ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
156
165
  * handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
157
166
  * So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
158
- * record through `readSiteWorkspace` — and a bare uuid here is a unit.
167
+ * login's choice as stored — and a bare uuid here is a unit.
159
168
  *
160
169
  * @param {unknown} value
161
170
  * @returns {string|null}
@@ -167,34 +176,45 @@ export function workspaceHeader(value) {
167
176
  }
168
177
 
169
178
  /**
170
- * The request named its workspace EXPLICITLY — `--org`, `--personal`, a choice made at
171
- * create — and the backend works on the site from another.
179
+ * The site is in another workspace than the one this command works in.
172
180
  *
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.
181
+ * ⛔ **Stopping is the point, and nothing is adopted.** A command works in ONE workspace —
182
+ * the one chosen with the login (`workspace.js`) — and a site outside it is refused, not
183
+ * worked on anyway *[Diego, 2026-09-23: "that's the intent of the workspace concept"]*.
184
+ * It is the backend's `409` that decides it, never a handle comparison here: a site in
185
+ * `@acme/labs` is legitimately worked on from `@acme`. ⚠️ *Until 2026-09-23 a workspace
186
+ * nobody had named was adopted from the `409` and the request retried.*
178
187
  */
179
188
  export class WorkspaceMismatchError extends Error {
180
189
  /**
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)
190
+ * @param {{ named: string|null, answer: string|null, source?: string }} p - the
191
+ * workspace the request named and the one the backend named (`@handle`, a unit's
192
+ * uuid, null for personal), and where the named one came from (`workspace.js`)
183
193
  */
184
- constructor({ named, answer }) {
194
+ constructor({ named, answer, source = 'login' }) {
185
195
  const where = (w) =>
186
196
  !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
- )
197
+ // How to work in the site's workspace, said for the way this one was chosen.
198
+ const switchTo = !answer
199
+ ? { flag: 'pass --personal', env: 'set UNIWEB_WORKSPACE=personal' }
200
+ : answer.startsWith('@')
201
+ ? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
202
+ : null
203
+ const login = !answer ? 'uniweb login --personal' : answer.startsWith('@') ? `uniweb login --org ${answer}` : null
204
+ const fix = !switchTo
205
+ ? 'It has no handle, so the CLI cannot work in it.'
206
+ : source === 'flag'
207
+ ? `To work on it, ${switchTo.flag}.`
208
+ : source === 'env'
209
+ ? `To work on it, ${switchTo.env}.`
210
+ : `To work on it: ${login}`
211
+ const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
212
+ super(`This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
194
213
  this.name = 'WorkspaceMismatchError'
195
214
  this.status = 409
196
215
  this.named = named
197
216
  this.answer = answer
217
+ this.source = source
198
218
  }
199
219
  }
200
220
 
@@ -270,8 +290,7 @@ export class BackendClient {
270
290
  this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
271
291
  this._discovery = null
272
292
  this._workspace = null
273
- this._workspaceExplicit = false
274
- this._onWorkspaceAdopted = null
293
+ this._workspaceSource = 'login'
275
294
  }
276
295
 
277
296
  /** The workspace this client's requests name — `@handle`, or null (none named). */
@@ -280,23 +299,19 @@ export class BackendClient {
280
299
  }
281
300
 
282
301
  /**
283
- * Name the workspace every request from here on works in (`x-uniweb-workspace`).
302
+ * Name the workspace every site request from here on works in (`x-uniweb-workspace`)
303
+ * — the one `resolveWorkspace` answered for this command.
284
304
  *
285
- * @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null to
286
- * name none (`workspaceHeader`)
305
+ * @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null for
306
+ * the personal workspace (`workspaceHeader`)
287
307
  * @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
308
+ * @param {string} [opts.source='login'] - where it came from (`workspace.js`), so a
309
+ * refusal says how to switch
294
310
  * @returns {this}
295
311
  */
296
- setWorkspace(value, { explicit = false, onAdopted = null } = {}) {
312
+ setWorkspace(value, { source = 'login' } = {}) {
297
313
  this._workspace = workspaceHeader(value)
298
- this._workspaceExplicit = Boolean(explicit)
299
- this._onWorkspaceAdopted = onAdopted || null
314
+ this._workspaceSource = source
300
315
  return this
301
316
  }
302
317
 
@@ -332,9 +347,8 @@ export class BackendClient {
332
347
  * applies query params, and infers a content-type from the body when unset
333
348
  * (string → application/json, Buffer/Uint8Array → application/zip). Returns
334
349
  * the raw Response so callers branch on status themselves (409 resume,
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).
350
+ * 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, thrown as
351
+ * `WorkspaceMismatchError`: the site is in another workspace than this command's.
338
352
  *
339
353
  * @param {string} path - leading-slash path, e.g. '/dev/site/content'
340
354
  * @param {object} [opts]
@@ -349,13 +363,10 @@ export class BackendClient {
349
363
  const res = await this._send(path, opts)
350
364
  if (res?.status !== 409) return res
351
365
 
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.
366
+ // ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request whose
367
+ // workspace does not contain the site is refused with `409 wrong_workspace`, naming a
368
+ // workspace the caller CAN open that does — the site's own unit, or a parent. The
369
+ // command works in one workspace, so this stops it (`WorkspaceMismatchError`).
359
370
  // ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
360
371
  const problem =
361
372
  typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
@@ -367,15 +378,14 @@ export class BackendClient {
367
378
  : typeof where.unit_uuid === 'string' && where.unit_uuid
368
379
  ? where.unit_uuid
369
380
  : 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.
381
+ // Refused while naming what the backend names: nothing to switch to; the caller reads
382
+ // the 409 like any other.
372
383
  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)
384
+ throw new WorkspaceMismatchError({
385
+ named: this._workspace,
386
+ answer,
387
+ source: this._workspaceSource
388
+ })
379
389
  }
380
390
 
381
391
  /** One request, as `request` documents it — naming the client's workspace on a site route. */
@@ -571,7 +581,7 @@ export class BackendClient {
571
581
 
572
582
  /** GET /dev/orgs → { account_handle, orgs[] }. */
573
583
  async fetchOrgs() {
574
- return fetchOrgsImpl({ apiBase: this.origin, token: await this.token() })
584
+ return fetchOrgsImpl({ apiBase: this.origin, token: await this.token(), fetchImpl: this._fetch })
575
585
  }
576
586
 
577
587
  /** POST /dev/orgs { handle } → { handle, uuid, is_primary }. Throws with the server's detail on 409/422. */