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 +7 -7
- package/partials/agents.md +19 -19
- package/src/backend/client.js +61 -51
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +237 -243
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +20 -30
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +37 -43
- package/src/commands/pull.js +232 -81
- package/src/commands/push.js +36 -45
- package/src/commands/status.js +24 -15
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +39 -27
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +12 -9
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
- package/src/utils/semver-precedence.js +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
46
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
47
|
+
"@uniweb/schemas": "^0.5.0",
|
|
46
48
|
"@uniweb/core": "^0.29.8",
|
|
47
|
-
"@uniweb/
|
|
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/
|
|
53
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
54
|
+
"@uniweb/build": "^0.60.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
> ⚠️ **
|
|
2577
|
-
>
|
|
2578
|
-
>
|
|
2579
|
-
> afterwards.
|
|
2580
|
-
>
|
|
2581
|
-
>
|
|
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
|
|
2585
|
-
> uniweb
|
|
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
|
-
>
|
|
2589
|
-
>
|
|
2590
|
-
>
|
|
2591
|
-
>
|
|
2592
|
-
>
|
|
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
|
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
|
182
|
-
* named
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
|
286
|
-
*
|
|
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 {
|
|
289
|
-
*
|
|
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, {
|
|
312
|
+
setWorkspace(value, { source = 'login' } = {}) {
|
|
297
313
|
this._workspace = workspaceHeader(value)
|
|
298
|
-
this.
|
|
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`,
|
|
336
|
-
*
|
|
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
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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
|
-
//
|
|
371
|
-
//
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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. */
|