uniweb 0.61.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 +7 -7
- package/partials/agents.md +17 -15
- package/src/backend/client.js +231 -20
- package/src/backend/foundation-bring-along.js +71 -65
- package/src/backend/site-sync.js +255 -127
- package/src/commands/add.js +62 -4
- package/src/commands/clone.js +36 -6
- package/src/commands/publish.js +33 -20
- package/src/commands/pull.js +68 -15
- package/src/commands/push.js +23 -17
- package/src/commands/register.js +234 -37
- package/src/commands/rename.js +12 -9
- package/src/commands/status.js +32 -7
- package/src/framework-index.json +8 -8
- package/src/index.js +43 -10
- package/src/utils/args.js +6 -14
- package/src/utils/flag-guard.js +17 -9
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +4 -1
- package/src/utils/registry-orgs.js +3 -2
- package/src/utils/scaffold.js +7 -5
- package/src/utils/site-data-upload.js +13 -2
- package/starter/foundation/main.js +2 -1
- package/templates/foundation/main.js.hbs +9 -0
- package/templates/site/site.yml.hbs +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
45
45
|
"@uniweb/kit": "^0.19.4",
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/core": "^0.29.8",
|
|
47
|
+
"@uniweb/runtime": "^0.27.4",
|
|
48
|
+
"@uniweb/schemas": "^0.3.4",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/build": "^0.
|
|
52
|
+
"@uniweb/build": "^0.59.0",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -129,7 +129,7 @@ my-project/
|
|
|
129
129
|
└── pnpm-workspace.yaml
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site
|
|
132
|
+
A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site` — a **workspace** name, how the site depends on it. What the foundation **registers as** (`@org/<name>`, the name sites pin) is `name` in its `main.js`, which `uniweb create` sets to the project's name. **`src` and `foundation` are never a foundation's name** — `uniweb register` refuses them, because every project in an org would register the same one.
|
|
133
133
|
|
|
134
134
|
- **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
|
|
135
135
|
- **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
|
|
@@ -845,13 +845,13 @@ It is a small database, not a page tree. Three things, deliberately separate:
|
|
|
845
845
|
```
|
|
846
846
|
site/
|
|
847
847
|
├── pages/
|
|
848
|
-
├── records/ # your site's records — every file
|
|
848
|
+
├── records/ # your site's records — every file in a schema folder is one
|
|
849
849
|
│ ├── article/ # the folder names their data schema
|
|
850
850
|
│ │ ├── getting-started.md
|
|
851
851
|
│ │ └── design-tips.md
|
|
852
|
-
│
|
|
853
|
-
│
|
|
854
|
-
|
|
852
|
+
│ ├── person/
|
|
853
|
+
│ │ └── alice.yml
|
|
854
|
+
│ └── folder.yml # optional: sorts records into folders
|
|
855
855
|
├── queries.yml # how content is REACHED
|
|
856
856
|
└── site.yml
|
|
857
857
|
```
|
|
@@ -864,7 +864,7 @@ site/
|
|
|
864
864
|
| `records/std/person/…` | `@std/person` — the shared standard set |
|
|
865
865
|
| `records/acme/project/…` | `@acme/project` — an org's |
|
|
866
866
|
|
|
867
|
-
Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
|
|
867
|
+
Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
|
|
868
868
|
|
|
869
869
|
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
870
870
|
|
|
@@ -877,9 +877,9 @@ Keep those folders flat: `records/article/design-tips.md` works; `records/articl
|
|
|
877
877
|
|
|
878
878
|
**One record per file, or many.** A single mapping at the top of a file makes one record and the filename stem becomes its `slug`. A top-level array (YAML/JSON) or a multi-entry `.bib` makes many, each carrying its own `slug`. You can mix both in one folder — an exported `refs.bib` beside a hand-written `extras.yml`.
|
|
879
879
|
|
|
880
|
-
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out
|
|
880
|
+
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. **`draft: true` keeps a record off the live site without deleting it**: it is still a record, in `records/`, and `pnpm dev` shows it so you can preview it, but a build for hosting leaves it out, and a backend you `uniweb push` it to keeps it in the site's folder without delivering it. (`published: false`, an older spelling, is refused.)
|
|
881
881
|
|
|
882
|
-
**`records.yml` — optional, and only for folders.** Every record sits at the top of the site's records folder unless `
|
|
882
|
+
**`records/folder.yml` — optional, and only for folders.** It is the one file in `records/` that is not a record, it moves with the directory (`paths.records`), and like a pages folder's `folder.yml` it describes the folder it sits in. Every record sits at the top of the site's records folder unless `folder.yml` places it in a sub-folder. Add one only when a query needs to ask for a *slice* of the records rather than all of them — most sites never do:
|
|
883
883
|
|
|
884
884
|
```yaml
|
|
885
885
|
- folder: archive
|
|
@@ -888,7 +888,7 @@ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `descript
|
|
|
888
888
|
- article/2025-*.md
|
|
889
889
|
```
|
|
890
890
|
|
|
891
|
-
A path under a folder is relative to `records/`, naming one file or matching many. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `
|
|
891
|
+
A path under a folder is relative to `records/`, naming one file or matching many. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
|
|
892
892
|
|
|
893
893
|
⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
|
|
894
894
|
|
|
@@ -942,7 +942,7 @@ pages/blog/
|
|
|
942
942
|
|
|
943
943
|
> **The record arrives as a single-element array under the key the component declares** — `content.data.recent[0]` for a component declaring `recent`, `content.data.article[0]` for one declaring `article: '@std/article'` over an `@std/article` query. The runtime never coerces it to an object. See *Data* in Part 4.
|
|
944
944
|
|
|
945
|
-
**Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
|
|
945
|
+
**Records with URLs of their own shape — `[...path]/`.** A folder named exactly `[...path]` (one fixed spelling) captures the rest of the URL: `/blog/my-post` and `/blog/rust/2025/my-post` both reach it. The capture yields three standard variables — `:path` (the whole capture), `:dir` (everything before the last segment), `:slug` (the last segment, the record's handle) — and the record is still delivered by its handle, so the section reads `content.data.recent[0]` as before. The same three variables exist under every parametric page: under `[slug]`, `:slug` and `:path` are the segment and `:dir` is empty. A record's URL is its folder placement plus its slug (`- folder: rust/2025` in `records/folder.yml` → `/blog/rust/2025/my-post`). A query may bind a part — `scope: :dir` exposes the folder branch, `where: { tag: :dir }` keeps it private — and an unbound or empty variable drops its clause, so one saved query serves the query's page and its parametric page, on a static site and a hosted one alike. Without `scope: :dir` the directory is decoration: the record is found by its handle wherever it sits. Reference: `reference/dynamic-routes.md`.
|
|
946
946
|
|
|
947
947
|
**Two options for bigger sets:**
|
|
948
948
|
|
|
@@ -1006,7 +1006,7 @@ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you re
|
|
|
1006
1006
|
|
|
1007
1007
|
Within the same directory, use normal relative imports (`./AIFeedbackCard`).
|
|
1008
1008
|
|
|
1009
|
-
**Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the
|
|
1009
|
+
**Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the foundation's identity — `name` (what it registers as, `@org/<name>`) and `description` — and the capabilities it provides — `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. The capabilities are read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
|
|
1010
1010
|
|
|
1011
1011
|
### Props interface
|
|
1012
1012
|
|
|
@@ -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.
|
|
@@ -2594,7 +2596,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2594
2596
|
|
|
2595
2597
|
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.
|
|
2596
2598
|
|
|
2597
|
-
**Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. 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`.
|
|
2599
|
+
**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`.
|
|
2598
2600
|
|
|
2599
2601
|
### Staying current
|
|
2600
2602
|
|
|
@@ -2630,10 +2632,10 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
|
|
|
2630
2632
|
|
|
2631
2633
|
| Field | Where used | Default | Purpose |
|
|
2632
2634
|
|---|---|---|---|
|
|
2633
|
-
| `id` | `uniweb register` | bare segment of a scoped `name` | The foundation's registered id — the bare name in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming on the registry doesn't ripple through site dependencies. |
|
|
2634
|
-
| `namespace` | `uniweb register` | none | Legacy explicit org-namespace override; equivalent to a scoped `package.json::name`. Rarely needed. |
|
|
2635
2635
|
| `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
|
|
2636
2636
|
|
|
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`.)
|
|
2638
|
+
|
|
2637
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.
|
|
2638
2640
|
|
|
2639
2641
|
You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** — you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
626
|
+
async createSiteContent(buffer) {
|
|
427
627
|
return this.request('/dev/site/content', {
|
|
428
628
|
method: 'POST',
|
|
429
629
|
body: buffer,
|
|
430
|
-
query:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
600
|
-
|
|
601
|
-
|
|
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' }
|