uniweb 0.85.0 → 0.87.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 +10 -10
- package/partials/agents.md +45 -29
- package/src/backend/client.js +59 -6
- package/src/backend/foundation-bring-along.js +5 -3
- package/src/backend/service-request.js +6 -2
- package/src/backend/site-preview.js +1 -1
- package/src/backend/site-sync.js +29 -12
- package/src/commands/build.js +6 -2
- package/src/commands/clone.js +1 -1
- package/src/commands/deploy.js +3 -3
- package/src/commands/forget.js +7 -7
- package/src/commands/publish.js +14 -7
- package/src/commands/pull.js +9 -9
- package/src/commands/push.js +13 -7
- package/src/commands/refresh.js +2 -1
- package/src/commands/register.js +10 -7
- package/src/commands/rename.js +6 -6
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +14 -5
- package/src/commands/status.js +1 -1
- package/src/framework-index.json +11 -11
- package/src/index.js +41 -11
- package/src/utils/config.js +13 -13
- package/src/utils/flag-guard.js +30 -16
- package/src/utils/site-identity.js +3 -3
- package/src/utils/yaml-edit.js +0 -115
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.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/
|
|
45
|
-
"@uniweb/runtime": "^0.29.
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/kit": "^0.20.0",
|
|
45
|
+
"@uniweb/runtime": "^0.29.4",
|
|
46
|
+
"@uniweb/core": "^0.38.0",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.5.1",
|
|
48
|
+
"@uniweb/schemas": "^0.13.0",
|
|
49
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/
|
|
53
|
-
"@uniweb/
|
|
54
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/build": "^0.80.0",
|
|
53
|
+
"@uniweb/semantic-parser": "^1.5.1",
|
|
54
|
+
"@uniweb/content-reader": "^1.2.8"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -899,7 +899,7 @@ brief:
|
|
|
899
899
|
The body — the value of the schema's content field, `body.content`.
|
|
900
900
|
```
|
|
901
901
|
|
|
902
|
-
**A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: '@std/article
|
|
902
|
+
**A component receives it in one of two shapes, and its `meta.js` says which**, per `data:` key — the same on a static site and a hosted one. `data: { articles: '@std/article' }` gets **briefs**: the brief's fields at the top (`article.title`), no other section. `data: { articles: { schema: '@std/article', whole: true } }` gets each record **whole**, as stored: every section under its own name, the brief's included (`article.brief.title`, `article.body.content`). Both carry `$name`, the handle. Lists carry briefs; a section that shows a record in full declares `whole: true`. A `fields:` schema's brief is the whole record, so its components need no `whole: true`. Full rules: `reference/entity-content.md`.
|
|
903
903
|
|
|
904
904
|
**A reference names the record it points at.** A `{ ref: '@/speaker' }` field is written as that record's name — `speaker: ada` for `records/speaker/ada.yml` (its file's name) — and a `many` one as a list of names. A component receives `{ entity, brief }`: the record reduced to its brief, so `talk.speaker.brief.name`. `uniweb validate` and a push report a name that matches no record; a push sends each reference to the record it names (in the same push when that record is new too), and a pull writes the name back.
|
|
905
905
|
|
|
@@ -962,15 +962,15 @@ pages/blog/
|
|
|
962
962
|
|
|
963
963
|
**Link a card with `item.$route` — never compose the URL.** Every record a query delivers carries `$route`, the URL of the parametric page whose route query is that query (`/blog/design-tips`), wherever the list appears — the query's own page, the homepage, a sidebar. No page for the query, or no value for the field its URL is built from, means no `$route`, never a broken one. `detailPage: page:<id>` on a fetch links the records to another page instead. `$` marks a field the framework fills, so a record's own `route` field is left alone. ⛔ `route:` on a query is retired and stops the build.
|
|
964
964
|
|
|
965
|
-
**Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record
|
|
965
|
+
**Which query the URL narrows — the page's route query:** the `[slug]` page's own `query:`, else its parent page's (the usual shape, above), else `site.yml`'s (for a top-level `pages/[slug]/` only — the site's reaches no deeper page); if none declares one, the query its sections all declare. The first query of that level wins. Every section the route query reaches gets the one record; a section declaring a *different* query of its own gets that query as declared. The folder name says what the URL segment matches: `[slug]` the record's name (`$name`, a compiled record's file name) — or the field its route query binds `:slug` to (`where: { slug: :slug }`, how an external API's records, which have no `$name`, get pages), `[uuid]` its `$uuid`, any other `[name]` the record's own field of that name — and when that field holds several values, **any member** matches (the record's own link is its first value). Routing by a field that is not unique picks one record and which one is not guaranteed; the build warns. A folder inside `[slug]/` (`[slug]/cv/` → `/blog/:slug/cv`) is a parametric page too, about the same record: its route query is the `[slug]` page's, wherever that is declared, and a query `cv/` declares itself arrives under its own key. **A section chooses how it uses the page's record with `current:`** on its own `fetch:` — `only` (the default: the record), `exclude` (the others: "related"), `include` (all of them: a pager) — e.g. `fetch: { query: articles, as: related, current: exclude, limit: 3 }`, where `limit` counts the others, all from the query's set. **`current:` follows the query, not the key:** a fetch of the route query gets the record unless it says otherwise, whatever its `as`; a fetch of another query gets that query's records and reads `current:` only when written (`exclude` drops the page's record, `only` keeps just it). `refine: true` / `detail: false` are retired and stop the build. `[dir]` and `[path]` are refused as folder names, and so is any folder inside `[...path]/`.
|
|
966
966
|
|
|
967
|
-
> **The record arrives
|
|
967
|
+
> **The record arrives under the key the component declares, as that key says** — one record for a key declared `single: true` (`content.data.article` for `article: { schema: '@std/article', single: true }` over an `@std/article` query; `null` when the URL names none), a single-element array for any other key (`content.data.recent[0]`). It is the record's brief, or the record whole where the key declares `whole: true` — a section that shows an article's body declares `whole: true`. See *Data* in Part 4.
|
|
968
968
|
|
|
969
|
-
**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
|
|
969
|
+
**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 it 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 own URL (`$route`, and the page a static build writes) is its name, as under `[slug]`: `/blog/my-post` wherever `records/folder.yml` places it — a record does not carry its folder. 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`.
|
|
970
970
|
|
|
971
971
|
**Two options for bigger sets:**
|
|
972
972
|
|
|
973
|
-
A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring
|
|
973
|
+
A list carries briefs, so a schema that keeps its heavy parts out of the brief — `@std/article`'s body is its own section — keeps lists light with no configuration, while a section declaring `whole: true` still receives the whole record on a `[slug]` page, and other components fetch it on demand with `useWholeRecord`. File-based records are written one file per record, whole, at `/data/<name>/<name of the record>.json`; an external query names its one-record request with `record:` instead (*Fetching from other sources* in Part 4). (`deferred:` is removed: a schema's brief decides what a list holds.)
|
|
974
974
|
|
|
975
975
|
`queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
|
|
976
976
|
|
|
@@ -1740,7 +1740,7 @@ source: education
|
|
|
1740
1740
|
|
|
1741
1741
|
### Custom layouts
|
|
1742
1742
|
|
|
1743
|
-
Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`.
|
|
1743
|
+
Layouts live in `layouts/` inside the foundation and are auto-discovered. Set `defaultLayout` in `main.js`, or name a layout `Default`. With neither, a page that names no layout gets the built-in layout — `header`, the page, `footer`, nothing else — and a `defaultLayout` that names none of your layouts stops the build.
|
|
1744
1744
|
|
|
1745
1745
|
```jsx
|
|
1746
1746
|
// layouts/DocsLayout/index.jsx
|
|
@@ -1873,21 +1873,34 @@ Content-less containers appear as group nodes (`hasContent: false`) — use `nav
|
|
|
1873
1873
|
|
|
1874
1874
|
**A section receives the keys its component declares in `meta.js` `data:` — and nothing else** (plus any its foundation declares in `main.js` `data:`). A component that reads `content.data.articles` declares `articles`. Each declared key is filled by, in order: a tagged data block in the section; else the fetch that fills it, level by level from the section's own to the site's — a fetch whose `as` (its query's name by default) is the key, or else, **automatic `as`**, the first fetch under an undeclared key whose query's schema is the key's (`@/x` matches any scope's `x`); else `null`. So `data: { related: '@std/article' }` receives an `articles` query under `related`. `as:` on a fetch picks the key when two keys or two fetches share a schema. A fetch that fills none of a section's keys is not requested for it.
|
|
1875
1875
|
|
|
1876
|
-
**A
|
|
1876
|
+
**A key says how many records it holds, and how much of each — two flags, independent.** A ref alone (`'@std/article'`) is a **list of briefs**: on the query's own page every record the fetch takes, on a parametric page (`[slug]/`) a single-element array, `[]` when nothing matches. Write the key long to change either:
|
|
1877
1877
|
|
|
1878
|
-
|
|
1878
|
+
```js
|
|
1879
|
+
data: {
|
|
1880
|
+
articles: '@std/article', // a list of briefs
|
|
1881
|
+
article: { schema: '@std/article', single: true, whole: true }, // one record, whole
|
|
1882
|
+
card: { schema: '@std/article', single: true }, // one record's brief
|
|
1883
|
+
archive: { schema: '@std/article', whole: true }, // a list of whole records
|
|
1884
|
+
}
|
|
1885
|
+
```
|
|
1886
|
+
|
|
1887
|
+
- **`single: true`** — the key holds **one record**: the first its binding selects (on a `[slug]` page, the record the URL names), or `null` when there is none. The runtime asks for just that one. A binding over many gives the first: sort the query, or give the binding `limit: 1`.
|
|
1888
|
+
- **`whole: true`** — each record **whole**, as stored: every section under its own name, the brief's included — `article.brief.title`, `article.body.content`. Without it, each record is its **brief**: the brief section's fields at the top, the section not named. The runtime asks for what the key declares; a `whole` key its source cannot answer whole is `null`. A `fields:` schema's brief is the whole record, so `whole` is only needed for a schema with more than one section.
|
|
1889
|
+
- The long form takes `schema`, `single` and `whole`, nothing else — the build stops on any other property, and on `single` or `whole` over a schema whose root is a list (`@std/nav`, `@std/form`: the key holds that list). ⛔ `'@std/article/*'` is retired and stops the build: write `whole: true`.
|
|
1890
|
+
|
|
1891
|
+
When nothing fills a key it is `null`. An external query's records have no schema and arrive as its source answers them. Reference: `reference/component-metadata.md` § *One record, or a list* and § *Briefs or whole records*.
|
|
1879
1892
|
|
|
1880
1893
|
```jsx
|
|
1881
|
-
function Article({ content, block }) {
|
|
1894
|
+
function Article({ content, block }) { // data: { article: { schema: '@std/article', single: true } }
|
|
1882
1895
|
if (block.dataLoading) return <DataPlaceholder />
|
|
1883
|
-
if (block.dataError?.
|
|
1884
|
-
const article = content.data.
|
|
1896
|
+
if (block.dataError?.article) return <LoadFailed /> // the request failed — not "no records"
|
|
1897
|
+
const article = content.data.article // the record the [slug] page's URL names, or null
|
|
1885
1898
|
if (!article) return <NotFound />
|
|
1886
1899
|
return <ArticleView article={article} />
|
|
1887
1900
|
}
|
|
1888
1901
|
```
|
|
1889
1902
|
|
|
1890
|
-
|
|
1903
|
+
Any other reshaping is the foundation's job — once, with a `handlers.data` hook.
|
|
1891
1904
|
|
|
1892
1905
|
**Declaring keys and schemas.** `meta.js` declares each `content.data` key the component receives, with its schema, in a single `data:` field — there is no separate `schemas:` key. Each value is a **named ref**, an **inline field map**, an **inline rich-form** (`{ fields: [...] }`, an editor form), or `{}` for a key with no schema (an external API's records). Refs resolve on disk at build time, never fetched: `@/name` (this foundation's `schemas/`), `@std/name` (shared standards, from `@uniweb/schemas`), `@org/name` (an org's own `@org/schemas` package). A schema drives the editor, checks your data (`uniweb validate`), and lets a fetch of another name fill the key — it fills no field: **a record reaches the component as it is**, so a field it lacks is absent, and what that renders as is your component's choice (`record.status ?? 'published'`). **A named schema declares no `default:`** — the build refuses one; a `default:` belongs only in an inline field map or form, where an editor pre-fills from it. ⛔ **Delivery was default-on until this change** — every fetched key reached every component, and `data:` was a hint; a component that reads a key it does not declare now receives nothing under it. `data: false` declares nothing, like no `data:`. Keys a foundation's handlers (or a shared hook) read go in `main.js` `data:`, in the same form, and every section receives them.
|
|
1893
1906
|
|
|
@@ -1915,7 +1928,7 @@ fetch:
|
|
|
1915
1928
|
limit: 3
|
|
1916
1929
|
```
|
|
1917
1930
|
|
|
1918
|
-
**Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares
|
|
1931
|
+
**Lean lists.** A list carries each record's brief, so heavy parts belong in a section of their own — `@std/article`'s body is `body` — and never ride a list. A section that shows them declares its key `whole: true`; elsewhere components fetch the whole record on demand with `useWholeRecord(record, { query })`, which returns it as stored — `full.body.content`. Every file-based record gets its own file, `/data/<name>/<name of the record>.json`. An external query's whole record comes from its `record:` request, below. (`deferred:` is removed — the build stops on it.) The hook is safe to call on any query: when the query has no separate source for one record it returns the record you passed in.
|
|
1919
1932
|
|
|
1920
1933
|
**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.
|
|
1921
1934
|
|
|
@@ -2026,7 +2039,7 @@ resolves by the same rule — the host's offer, then the site's own address, the
|
|
|
2026
2039
|
neither.
|
|
2027
2040
|
|
|
2028
2041
|
⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
|
|
2029
|
-
no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `
|
|
2042
|
+
no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isBackendEnabled()`, `isAssistantEnabled()`,
|
|
2030
2043
|
`isTrackingEnabled()`.
|
|
2031
2044
|
|
|
2032
2045
|
```jsx
|
|
@@ -2457,7 +2470,7 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
|
|
|
2457
2470
|
## Part 4b — When the site is also an app
|
|
2458
2471
|
|
|
2459
2472
|
Everything above is a site: content the author writes, built into pages. Some sites
|
|
2460
|
-
also have **
|
|
2473
|
+
also have **their own backend, the `backend` service** — accounts, per-visitor data, records their members
|
|
2461
2474
|
create and edit. `@uniweb/api` is its client.
|
|
2462
2475
|
|
|
2463
2476
|
⛔ **Only reach for this when the site actually has one.** A site without one is
|
|
@@ -2471,13 +2484,13 @@ npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
|
|
|
2471
2484
|
### Ask before you draw
|
|
2472
2485
|
|
|
2473
2486
|
```jsx
|
|
2474
|
-
import {
|
|
2487
|
+
import { isBackendEnabled } from '@uniweb/kit'
|
|
2475
2488
|
import { useSession, SignedIn, SignedOut } from '@uniweb/api'
|
|
2476
2489
|
|
|
2477
|
-
if (!
|
|
2490
|
+
if (!isBackendEnabled()) return <StaticVersion /> // synchronous — nothing to await
|
|
2478
2491
|
```
|
|
2479
2492
|
|
|
2480
|
-
⛔ **When the site has no `
|
|
2493
|
+
⛔ **When the site has no `backend` service, draw nothing** — not a disabled control, and not an
|
|
2481
2494
|
explanation. Same rule as `services` in Part 4: which capabilities a site's operator
|
|
2482
2495
|
set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
|
|
2483
2496
|
when it is simply a feature this site does not have. Render the version of your
|
|
@@ -2499,7 +2512,7 @@ remove it. `scope: 'mine'` lists only the viewer's own records. Without it, the
|
|
|
2499
2512
|
also gets everyone else's.
|
|
2500
2513
|
|
|
2501
2514
|
⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
|
|
2502
|
-
mistake to avoid.** `absent` = there is no live source (no `
|
|
2515
|
+
mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
|
|
2503
2516
|
in) → render the site's authored content. `ready` with `records: []` = the service
|
|
2504
2517
|
answered and there is nothing there → render your empty state. Showing "nothing yet"
|
|
2505
2518
|
for the first tells a visitor their content is gone when it was never requested.
|
|
@@ -2556,8 +2569,8 @@ You do not need a live backend to build against one. In `site.yml`:
|
|
|
2556
2569
|
|
|
2557
2570
|
```yaml
|
|
2558
2571
|
services:
|
|
2559
|
-
|
|
2560
|
-
$
|
|
2572
|
+
backend: true # ask your host for it when you publish
|
|
2573
|
+
$devBackend: ./mock/api.js # what answers it in `uniweb dev`; `$` keys are never published
|
|
2561
2574
|
```
|
|
2562
2575
|
|
|
2563
2576
|
```js
|
|
@@ -2566,7 +2579,7 @@ import { createMockBackend } from '@uniweb/api/mock'
|
|
|
2566
2579
|
export default createMockBackend({ seed }).fetch
|
|
2567
2580
|
```
|
|
2568
2581
|
|
|
2569
|
-
`uniweb dev` answers your site's `
|
|
2582
|
+
`uniweb dev` answers your site's `backend` service with it, at an address the dev server
|
|
2570
2583
|
supplies on its own origin, so cookies behave exactly as they will in production. Write
|
|
2571
2584
|
no address of your own for it: under `services:` an address means a backend you run, and
|
|
2572
2585
|
asks your host to leave its own off. It **enforces** who
|
|
@@ -2596,7 +2609,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
|
|
|
2596
2609
|
uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
|
|
2597
2610
|
uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
|
|
2598
2611
|
uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
|
|
2599
|
-
uniweb login / logout # One backend, one workspace at a time: log in (--
|
|
2612
|
+
uniweb login / logout # One backend, one workspace at a time: log in (--server, --org) or out
|
|
2600
2613
|
uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
|
|
2601
2614
|
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
2602
2615
|
|
|
@@ -2673,7 +2686,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2673
2686
|
|
|
2674
2687
|
**Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
|
|
2675
2688
|
|
|
2676
|
-
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --
|
|
2689
|
+
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
|
|
2677
2690
|
|
|
2678
2691
|
**`services:` in `site.yml` is where a site says which services it uses** — site search, form
|
|
2679
2692
|
handling, analytics, an assistant, accounts — one entry per service:
|
|
@@ -2683,12 +2696,13 @@ services:
|
|
|
2683
2696
|
search: true # on — your host's, or the built-in index
|
|
2684
2697
|
submit: false # off
|
|
2685
2698
|
tracking: https://collector.example.com/events # a provider you bring
|
|
2686
|
-
|
|
2699
|
+
backend: # on, with the service's own settings
|
|
2687
2700
|
grade: pro
|
|
2688
2701
|
```
|
|
2689
2702
|
|
|
2690
2703
|
An entry is `true`, `false`, an address of your own (a string, or `endpoint:` in a map), or a map
|
|
2691
|
-
of options
|
|
2704
|
+
of options; anything else stops the build and the push. ⚠️ `yes`, `no`, `on` and `off` are text to
|
|
2705
|
+
YAML, not switches — write `true` or `false`. On a site you push or publish it is also **what you ask your host for**: `true` asks for
|
|
2692
2706
|
its service, `false` asks it to turn its service off, and an address asks it to leave its own off so
|
|
2693
2707
|
yours answers. **On a site your host publishes, a service is off unless `services:` asks for it** —
|
|
2694
2708
|
list the ones the site uses (the templates list theirs). `records: true` asks for your records to be
|
|
@@ -2701,15 +2715,17 @@ your last pull) is left as it is, and one changed both in the file and on the si
|
|
|
2701
2715
|
pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
|
|
2702
2716
|
has into `services:` (an off service with no settings only where the file already names it) — pull
|
|
2703
2717
|
before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
|
|
2704
|
-
credential is public** — it is built into the site, except `
|
|
2718
|
+
credential is public** — it is built into the site, except `backend`'s settings, which only your host
|
|
2705
2719
|
reads; a key or token is set in the app. ⛔ The top-level `search:` / `submit:` / `assistant:` /
|
|
2706
|
-
`tracking:` / `api:` keys are retired: the build stops on them and says where each one moves.
|
|
2720
|
+
`tracking:` / `api:` keys are retired: the build stops on them and says where each one moves. So
|
|
2721
|
+
does `api` under `services:` — the site's own backend is the `backend` service — and `$devApi:`,
|
|
2722
|
+
which is `$devBackend:`.
|
|
2707
2723
|
|
|
2708
2724
|
**The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
|
|
2709
2725
|
|
|
2710
2726
|
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.
|
|
2711
2727
|
|
|
2712
|
-
**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). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. 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 --
|
|
2728
|
+
**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). The version it picks is always the next patch: for a change that is not backwards compatible, set a higher minor or major version in the foundation's `package.json` yourself before you push. 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 --server <url> --token <bearer>` without a terminal) or `UNIWEB_TOKEN`; preview with `--dry-run`.
|
|
2713
2729
|
|
|
2714
2730
|
### Staying current
|
|
2715
2731
|
|
package/src/backend/client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `fetch(… Authorization: Bearer …)` against a per-command default origin. It
|
|
8
8
|
* owns the three things that were previously scattered across a dozen files:
|
|
9
9
|
*
|
|
10
|
-
* 1. ORIGIN — where the backend is. resolveBackendOrigin():
|
|
10
|
+
* 1. ORIGIN — where the backend is. resolveBackendOrigin(): UNIWEB_SERVER >
|
|
11
11
|
* the backend you are logged in to > the default backend (below).
|
|
12
12
|
* Each is reduced to its origin, http(s) only. *(This named a
|
|
13
13
|
* `--backend` flag as the first tier until 2026-10-05; the verbs
|
|
@@ -46,7 +46,7 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
|
|
|
46
46
|
/**
|
|
47
47
|
* Resolve the backend a command talks to:
|
|
48
48
|
*
|
|
49
|
-
* 1.
|
|
49
|
+
* 1. UNIWEB_SERVER env — the override for automation (CI, scripts), one process
|
|
50
50
|
* 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
|
|
51
51
|
* 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
|
|
52
52
|
* where the command's first request then asks the user to log in
|
|
@@ -60,9 +60,10 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
|
|
|
60
60
|
* ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
|
|
61
61
|
* `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
|
|
62
62
|
* slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
|
|
63
|
-
* a script aims with
|
|
63
|
+
* a script aims with UNIWEB_SERVER without touching the machine's login. A
|
|
64
64
|
* project's sync.json and deploy.yml routed commands too, until the same day.
|
|
65
|
-
*
|
|
65
|
+
* A backend flag survives only where it SELECTS rather than routes — `--server`, `--backend`
|
|
66
|
+
* until 2026-10-07: `login` (where to log
|
|
66
67
|
* in) and `forget` (which backend's records to remove). `logout` needs none: there is
|
|
67
68
|
* one session.
|
|
68
69
|
*
|
|
@@ -227,6 +228,27 @@ export class WorkspaceMismatchError extends Error {
|
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The backend could not be reached at all — the request never got an answer.
|
|
233
|
+
*
|
|
234
|
+
* ⛔ Not an answer, so it must not be read as one. A lookup that folds this into "not
|
|
235
|
+
* found" sends its caller down the not-found path: `readFoundationLatest` did, and a
|
|
236
|
+
* publish with the backend down announced "Releasing the foundation (not yet
|
|
237
|
+
* registered)…", failed inside `register`, and ended on "Fix the foundation".
|
|
238
|
+
*/
|
|
239
|
+
export class BackendUnreachableError extends Error {
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} origin
|
|
242
|
+
* @param {unknown} cause - the transport error `fetch` threw
|
|
243
|
+
*/
|
|
244
|
+
constructor(origin, cause) {
|
|
245
|
+
super(`Could not reach the backend at ${origin}: ${cause?.message ?? cause}`)
|
|
246
|
+
this.name = 'BackendUnreachableError'
|
|
247
|
+
this.origin = origin
|
|
248
|
+
this.cause = cause
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
230
252
|
/**
|
|
231
253
|
* The line to print when a request THREW rather than answered.
|
|
232
254
|
*
|
|
@@ -562,7 +584,9 @@ export class BackendClient {
|
|
|
562
584
|
|
|
563
585
|
/**
|
|
564
586
|
* GET /dev/registry/{scope}/{name} → the latest registered foundation version
|
|
565
|
-
* + its content digest, or null
|
|
587
|
+
* + its content digest, or null when the backend answers with anything else (callers
|
|
588
|
+
* degrade). ⛔ A backend that cannot be reached is not an answer: that throws
|
|
589
|
+
* `BackendUnreachableError` rather than reading as "not registered".
|
|
566
590
|
*
|
|
567
591
|
* The bare `{scope}/{name}` path resolves the latest foundation version (the
|
|
568
592
|
* data-schema sibling is `/dev/registry/data-schemas/{scope}/{name}`). The
|
|
@@ -589,7 +613,11 @@ export class BackendClient {
|
|
|
589
613
|
...body,
|
|
590
614
|
latest_version: body.latest_version ?? body.version ?? null
|
|
591
615
|
}
|
|
592
|
-
} catch {
|
|
616
|
+
} catch (err) {
|
|
617
|
+
// Node's `fetch` throws `TypeError: fetch failed` when nothing answered.
|
|
618
|
+
if (err instanceof TypeError && /fetch failed/i.test(err.message)) {
|
|
619
|
+
throw new BackendUnreachableError(this.origin, err)
|
|
620
|
+
}
|
|
593
621
|
return null
|
|
594
622
|
}
|
|
595
623
|
}
|
|
@@ -785,6 +813,31 @@ export class BackendClient {
|
|
|
785
813
|
})
|
|
786
814
|
}
|
|
787
815
|
|
|
816
|
+
/**
|
|
817
|
+
* GET /dev/site — one page of the sites in the workspace the client names
|
|
818
|
+
* (`setWorkspace`; none ⇒ personal): `{ sites: [{ uuid, name?, updated_at,
|
|
819
|
+
* deployment?: { status, published_url, … } }] }`. A page shorter than `limit` is
|
|
820
|
+
* the last. `deployment` is absent for a site never published.
|
|
821
|
+
*
|
|
822
|
+
* @param {{ limit?: number, offset?: number }} [opts]
|
|
823
|
+
* @returns {Promise<Response>}
|
|
824
|
+
*/
|
|
825
|
+
async listSites({ limit = 100, offset = 0 } = {}) {
|
|
826
|
+
return this.request('/dev/site', { query: { limit, offset } })
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* DELETE /dev/site/{uuid} — delete a site, finally: no trash, no restore. Only an
|
|
831
|
+
* admin of the site may. Refused `409` with `blockers: [{ resource, detail }]` while
|
|
832
|
+
* anything is still active — `published` among them, which `unpublishSite` clears.
|
|
833
|
+
*
|
|
834
|
+
* @param {string} uuid - the site-content uuid
|
|
835
|
+
* @returns {Promise<Response>}
|
|
836
|
+
*/
|
|
837
|
+
async deleteSite(uuid) {
|
|
838
|
+
return this.request(`/dev/site/${encodeURIComponent(uuid)}`, { method: 'DELETE' })
|
|
839
|
+
}
|
|
840
|
+
|
|
788
841
|
/**
|
|
789
842
|
* GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
|
|
790
843
|
* shipped backend-side — collab backend↔framework):
|
|
@@ -197,7 +197,7 @@ function writePkgVersion(dir, version) {
|
|
|
197
197
|
// and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
|
|
198
198
|
// the child's environment (releaseFoundation), and the SESSION is the shared session
|
|
199
199
|
// file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
|
|
200
|
-
// `--
|
|
200
|
+
// backend flag (`--server` is the login's) and no `--token`.
|
|
201
201
|
//
|
|
202
202
|
// ⭐ The workspace travels because a bare name registers under the workspace the command
|
|
203
203
|
// works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
|
|
@@ -577,7 +577,9 @@ function buildFoundation(local, cliBin) {
|
|
|
577
577
|
execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
|
|
578
578
|
cwd: local.dir,
|
|
579
579
|
stdio: 'inherit',
|
|
580
|
-
|
|
580
|
+
// A step of this command, so the build skips the next-step hints meant for
|
|
581
|
+
// someone who ran `uniweb build` themselves.
|
|
582
|
+
env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
|
|
581
583
|
})
|
|
582
584
|
}
|
|
583
585
|
|
|
@@ -592,7 +594,7 @@ function releaseFoundation(local, args, cliBin, say, origin) {
|
|
|
592
594
|
execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
|
|
593
595
|
cwd: local.dir,
|
|
594
596
|
stdio: 'inherit',
|
|
595
|
-
env: origin ? { ...process.env,
|
|
597
|
+
env: origin ? { ...process.env, UNIWEB_SERVER: origin } : process.env
|
|
596
598
|
})
|
|
597
599
|
console.log('')
|
|
598
600
|
return true
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { createHash } from 'node:crypto'
|
|
29
29
|
import { existsSync, readFileSync } from 'node:fs'
|
|
30
30
|
import { join } from 'node:path'
|
|
31
|
-
import { readServicesRequest } from '@uniweb/build/uwx'
|
|
31
|
+
import { readServicesRequest, unreadableServices } from '@uniweb/build/uwx'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A stable fingerprint of one declared value, or `null` when the key is absent.
|
|
@@ -148,7 +148,7 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Say what `site.yml::services` asks that will not be sent as written — a credential,
|
|
151
|
-
* an entry that is not one,
|
|
151
|
+
* an entry that is not one, a `backend` address — and where it and the foundation disagree,
|
|
152
152
|
* before a push or publish sends the rest. What a push sends is the producer's
|
|
153
153
|
* (`statedServices`), from the same file.
|
|
154
154
|
*
|
|
@@ -165,6 +165,10 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
|
165
165
|
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
166
166
|
*/
|
|
167
167
|
export function announceServices({ siteYml, say, supports = null }) {
|
|
168
|
+
// An entry the push cannot read stops the package build next, which says what to write
|
|
169
|
+
// (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
|
|
170
|
+
// past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
|
|
171
|
+
if (unreadableServices(siteYml?.services).length) return
|
|
168
172
|
const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
|
|
169
173
|
if (!Array.isArray(supports)) return
|
|
170
174
|
const ignored = new Set(['tracking', 'records'])
|
|
@@ -46,7 +46,7 @@ export async function readSitePreview({ siteDir, args = [], client = null }) {
|
|
|
46
46
|
refused: [
|
|
47
47
|
`This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
|
|
48
48
|
known.length
|
|
49
|
-
? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --
|
|
49
|
+
? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --server <url>`
|
|
50
50
|
: `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
|
|
51
51
|
]
|
|
52
52
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
15
|
-
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
15
|
+
import { join, dirname, relative, isAbsolute, basename } from 'node:path'
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
18
|
import { recordWritten } from '../utils/pull-written.js'
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
updateBackendMap,
|
|
45
45
|
clearBackendSections,
|
|
46
46
|
normalizeBackendOrigin,
|
|
47
|
-
|
|
47
|
+
writeSiteConfig,
|
|
48
48
|
harvestRecordItems,
|
|
49
49
|
storedRecordItems,
|
|
50
50
|
reprintRecordItems,
|
|
@@ -54,7 +54,8 @@ import {
|
|
|
54
54
|
writeRegisteredFoundation,
|
|
55
55
|
backfillLinkUuids,
|
|
56
56
|
LINK_MODEL,
|
|
57
|
-
bankedFileUuids
|
|
57
|
+
bankedFileUuids,
|
|
58
|
+
existingSectionFile
|
|
58
59
|
} from '@uniweb/build/uwx'
|
|
59
60
|
|
|
60
61
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -295,7 +296,15 @@ export function pulledItemVersions(doc, itemVersions) {
|
|
|
295
296
|
// The files a unit projects to, under the site's own roots — `site.yml::paths` can
|
|
296
297
|
// relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
|
|
297
298
|
// to three files.
|
|
298
|
-
|
|
299
|
+
//
|
|
300
|
+
// ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
|
|
301
|
+
// file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
|
|
302
|
+
// pull writes back into in place. So the unit is looked up as a pull finds it
|
|
303
|
+
// (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file: a push
|
|
304
|
+
// recorded no file for a numbered section, and the next `pull --merge` merged against an
|
|
305
|
+
// older version — a false conflict on a line only the other side had changed (measured
|
|
306
|
+
// on the starter's `1-welcome.md`).
|
|
307
|
+
export function unitFilesOf(siteDir, unitPath) {
|
|
299
308
|
if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
|
|
300
309
|
let paths = {}
|
|
301
310
|
try {
|
|
@@ -304,7 +313,12 @@ function unitFilesOf(siteDir, unitPath) {
|
|
|
304
313
|
/* no or unreadable site.yml — the defaults are right */
|
|
305
314
|
}
|
|
306
315
|
for (const root of ['pages', 'layout']) {
|
|
307
|
-
if (unitPath.startsWith(`${root}/`))
|
|
316
|
+
if (!unitPath.startsWith(`${root}/`)) continue
|
|
317
|
+
const rel = `${paths[root] || root}${unitPath.slice(root.length)}`
|
|
318
|
+
if (!rel.endsWith('.md') || existsSync(join(siteDir, rel))) return [rel]
|
|
319
|
+
const id = basename(rel, '.md')
|
|
320
|
+
const found = existingSectionFile(join(siteDir, dirname(rel)), id, id, { child: true })
|
|
321
|
+
return [found ? relative(siteDir, found) : rel]
|
|
308
322
|
}
|
|
309
323
|
return []
|
|
310
324
|
}
|
|
@@ -571,7 +585,7 @@ const readMap = (siteDir, backend, key) => {
|
|
|
571
585
|
* not change** — a partial site, published successfully, with nothing to
|
|
572
586
|
* indicate it.
|
|
573
587
|
*
|
|
574
|
-
* The guidance is `uniweb forget --
|
|
588
|
+
* The guidance is `uniweb forget --server <url>` now, which removes the uuid and the
|
|
575
589
|
* maps together, so following it no longer leads here.
|
|
576
590
|
*
|
|
577
591
|
* Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
|
|
@@ -718,7 +732,7 @@ export function dropSiteBoundValues(siteDir, backend) {
|
|
|
718
732
|
if (
|
|
719
733
|
y.preview !== undefined &&
|
|
720
734
|
!isAuthoredPreview(y.preview) &&
|
|
721
|
-
|
|
735
|
+
writeSiteConfig(siteDir, { preview: null }) === 'updated'
|
|
722
736
|
) {
|
|
723
737
|
dropped.push('preview')
|
|
724
738
|
}
|
|
@@ -1902,7 +1916,7 @@ export async function pushSyncPackages({
|
|
|
1902
1916
|
} catch (err) {
|
|
1903
1917
|
error(describeRequestError(err, client.origin))
|
|
1904
1918
|
if (!(err instanceof WorkspaceMismatchError))
|
|
1905
|
-
note('Is that the backend you meant? Switch with: uniweb login --
|
|
1919
|
+
note('Is that the backend you meant? Switch with: uniweb login --server <url>')
|
|
1906
1920
|
return null
|
|
1907
1921
|
}
|
|
1908
1922
|
if (!res.ok) {
|
|
@@ -2067,7 +2081,7 @@ export async function pushSyncPackages({
|
|
|
2067
2081
|
error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
|
|
2068
2082
|
if (res.status === 401 || res.status === 403) {
|
|
2069
2083
|
note(
|
|
2070
|
-
"Credentials weren't accepted — log in again (`uniweb login --
|
|
2084
|
+
"Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
|
|
2071
2085
|
)
|
|
2072
2086
|
} else if (res.status === 404 && boundUuid) {
|
|
2073
2087
|
// The clone is bound to a site the backend does not have. There is no CLI
|
|
@@ -2097,10 +2111,10 @@ export async function pushSyncPackages({
|
|
|
2097
2111
|
'Two causes. Check the cheap one first: is this the backend the site lives on?'
|
|
2098
2112
|
)
|
|
2099
2113
|
note(
|
|
2100
|
-
` wrong backend → uniweb login --
|
|
2114
|
+
` wrong backend → uniweb login --server <the right one> (nothing is lost)`
|
|
2101
2115
|
)
|
|
2102
2116
|
note(
|
|
2103
|
-
` deleted there → uniweb forget --
|
|
2117
|
+
` deleted there → uniweb forget --server ${client.origin}, then push again: it creates a NEW site`
|
|
2104
2118
|
)
|
|
2105
2119
|
note('Deleting this folder removes only your local copy, either way.')
|
|
2106
2120
|
} else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
|
|
@@ -2467,7 +2481,10 @@ export async function pushSyncPackages({
|
|
|
2467
2481
|
// What the backend holds that this copy doesn't. None of it was overwritten, and no
|
|
2468
2482
|
// later push will overwrite it — but the files are behind until a pull.
|
|
2469
2483
|
if (notHeld.kept.length || notHeld.foreign.length) {
|
|
2470
|
-
|
|
2484
|
+
// Named by the file it is in here, not by its id (`unitFilesOf`).
|
|
2485
|
+
const paths = [
|
|
2486
|
+
...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
|
|
2487
|
+
].sort()
|
|
2471
2488
|
note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
|
|
2472
2489
|
note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
|
|
2473
2490
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -138,10 +138,11 @@ function detectProjectType(projectDir) {
|
|
|
138
138
|
*/
|
|
139
139
|
function runCommand(command, args, cwd) {
|
|
140
140
|
return new Promise((resolve, reject) => {
|
|
141
|
+
// No shell: the command is node and a script path, which needs none — and a shell
|
|
142
|
+
// splits a path with a space in it. (Node warns DEP0190 on args passed with one.)
|
|
141
143
|
const proc = spawn(command, args, {
|
|
142
144
|
cwd,
|
|
143
|
-
stdio: 'inherit'
|
|
144
|
-
shell: true
|
|
145
|
+
stdio: 'inherit'
|
|
145
146
|
})
|
|
146
147
|
|
|
147
148
|
proc.on('close', (code) => {
|
|
@@ -246,6 +247,9 @@ async function buildFoundation(projectDir, options = {}) {
|
|
|
246
247
|
log('')
|
|
247
248
|
log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
|
|
248
249
|
|
|
250
|
+
// The next step is for someone who ran `uniweb build` — not for a push or publish
|
|
251
|
+
// that builds the foundation as one of its own steps (UNIWEB_BUILD_STEP).
|
|
252
|
+
if (process.env.UNIWEB_BUILD_STEP) return
|
|
249
253
|
log('')
|
|
250
254
|
log(`${colors.bright}Share with clients:${colors.reset}`)
|
|
251
255
|
log(
|
package/src/commands/clone.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* another workspace stops it.
|
|
45
45
|
*
|
|
46
46
|
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
47
|
-
*
|
|
47
|
+
* UNIWEB_SERVER > the local default (internal dev overrides;
|
|
48
48
|
* not the user-facing path — `uniweb login` determines the origin).
|
|
49
49
|
* Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
|
|
50
50
|
* from the backend commands 2026-09-21).
|