uniweb 0.33.1 → 0.34.1
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 +73 -38
- package/src/backend/client.js +122 -35
- package/src/backend/site-sync.js +42 -43
- package/src/commands/build.js +17 -17
- package/src/commands/clone.js +7 -7
- package/src/commands/content.js +2 -3
- package/src/commands/doctor.js +34 -10
- package/src/commands/i18n.js +53 -39
- package/src/commands/publish.js +39 -15
- package/src/commands/pull.js +26 -25
- package/src/commands/push.js +24 -14
- package/src/commands/validate.js +1 -1
- package/src/framework-index.json +16 -9
- package/src/index.js +39 -4
- package/src/utils/flag-guard.js +2 -2
- package/src/utils/git.js +8 -2
- package/src/utils/records-guard.js +80 -0
- package/src/utils/schemaless-report.js +4 -4
- package/src/utils/site-data-upload.js +2 -2
- package/src/utils/site-identity.js +65 -2
- package/templates/site/site.yml.hbs +24 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
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/
|
|
45
|
-
"@uniweb/kit": "^0.
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/runtime": "^0.13.3",
|
|
45
|
+
"@uniweb/kit": "^0.15.0",
|
|
46
|
+
"@uniweb/core": "^0.14.1",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.29.1",
|
|
51
50
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
-
"@uniweb/semantic-parser": "^1.3.1"
|
|
51
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
52
|
+
"@uniweb/build": "^0.30.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -770,24 +770,36 @@ seo:
|
|
|
770
770
|
|
|
771
771
|
That emits a `Content-Signal:` line in `robots.txt`. Declare only what you mean — an omitted signal says nothing, which is not the same as saying no.
|
|
772
772
|
|
|
773
|
-
###
|
|
773
|
+
### Records, queries and dynamic routes
|
|
774
774
|
|
|
775
|
-
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **
|
|
775
|
+
Most content lives in `pages/` — a fixed composition of sections on a fixed set of pages. **The other kind is a set of records: repeating content managed as one file per item**, that pages pull from. Blog posts, team members, products, case studies, bibliographies.
|
|
776
776
|
|
|
777
|
-
|
|
777
|
+
It is a small database, not a page tree. Three things, deliberately separate:
|
|
778
778
|
|
|
779
779
|
```
|
|
780
780
|
site/
|
|
781
781
|
├── pages/
|
|
782
|
-
├──
|
|
783
|
-
│ ├──
|
|
782
|
+
├── entities/ # your stored things. The folder names their data schema
|
|
783
|
+
│ ├── article/
|
|
784
784
|
│ │ ├── getting-started.md
|
|
785
785
|
│ │ └── design-tips.md
|
|
786
|
-
│ └──
|
|
786
|
+
│ └── person/
|
|
787
787
|
│ └── alice.yml
|
|
788
|
+
├── records.yml # what is PUBLISHED
|
|
789
|
+
├── queries.yml # how content is REACHED
|
|
788
790
|
└── site.yml
|
|
789
791
|
```
|
|
790
792
|
|
|
793
|
+
**`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
|
|
794
|
+
|
|
795
|
+
| on disk | schema |
|
|
796
|
+
|---|---|
|
|
797
|
+
| `entities/article/…` | `@/article` — your foundation's own |
|
|
798
|
+
| `entities/std/person/…` | `@std/person` — the shared standard set |
|
|
799
|
+
| `entities/acme/project/…` | `@acme/project` — an org's |
|
|
800
|
+
|
|
801
|
+
Keep those folders flat: `entities/article/design-tips.md` works; `entities/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.
|
|
802
|
+
|
|
791
803
|
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
792
804
|
|
|
793
805
|
| Format | Best for | Notes |
|
|
@@ -799,62 +811,85 @@ site/
|
|
|
799
811
|
|
|
800
812
|
**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`.
|
|
801
813
|
|
|
802
|
-
**Keep collection folders flat.** `collections/articles/design-tips.md` works; `collections/articles/2025/design-tips.md` does not.
|
|
803
|
-
|
|
804
814
|
Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
|
|
805
815
|
|
|
806
|
-
|
|
816
|
+
**`records.yml` — listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
|
|
807
817
|
|
|
808
818
|
```yaml
|
|
809
|
-
|
|
810
|
-
|
|
819
|
+
- article/*.md
|
|
820
|
+
- person/*.yml
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
A bare string is a path under `entities/`, naming one file or matching many.
|
|
824
|
+
|
|
825
|
+
⚠️ **An empty `records.yml` is not the same as having none.** No file means "leave the published set alone". An empty file means "the folder holds nothing", which REMOVES what is published. The CLI asks before it does that.
|
|
826
|
+
|
|
827
|
+
**Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
|
|
811
828
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
829
|
+
```yaml
|
|
830
|
+
- article/2026-*.md
|
|
831
|
+
- folder: archive
|
|
832
|
+
label: The Archive
|
|
833
|
+
records:
|
|
834
|
+
- article/2025-*.md
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
A query then slices it with `where: { path: { under: 'archive' } }`. An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
|
|
838
|
+
|
|
839
|
+
**`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
|
|
840
|
+
|
|
841
|
+
```yaml
|
|
842
|
+
recent:
|
|
843
|
+
schema: '@/article'
|
|
844
|
+
sort: date desc # `date desc`, `title asc`, …
|
|
845
|
+
limit: 10
|
|
846
|
+
|
|
847
|
+
team:
|
|
848
|
+
schema: '@std/person'
|
|
849
|
+
where: { published: { ne: false } } # a predicate — see authoring/predicates.md
|
|
817
850
|
```
|
|
818
851
|
|
|
819
|
-
|
|
852
|
+
You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
|
|
853
|
+
|
|
854
|
+
**Show a query on a page** with `data:` in `page.yml` (the whole result), or `fetch:` in a section's frontmatter (a subset):
|
|
820
855
|
|
|
821
856
|
```yaml
|
|
822
857
|
# pages/blog/page.yml | # a section on the homepage
|
|
823
858
|
title: Blog | ---
|
|
824
|
-
data:
|
|
825
|
-
| fetch: {
|
|
859
|
+
data: recent | type: ArticleTeaser
|
|
860
|
+
| fetch: { query: recent, limit: 3 }
|
|
826
861
|
| ---
|
|
827
862
|
```
|
|
828
863
|
|
|
829
|
-
**Give each
|
|
864
|
+
**Give each record its own page with a `[slug]/` folder** under the list page:
|
|
830
865
|
|
|
831
866
|
```
|
|
832
867
|
pages/blog/
|
|
833
|
-
├── page.yml # title: Blog / data:
|
|
868
|
+
├── page.yml # title: Blog / data: recent
|
|
834
869
|
├── list.md
|
|
835
870
|
└── [slug]/
|
|
836
871
|
├── page.yml
|
|
837
|
-
└── article.md # just `type: Article` — the
|
|
872
|
+
└── article.md # just `type: Article` — the record arrives automatically
|
|
838
873
|
```
|
|
839
874
|
|
|
840
|
-
`
|
|
875
|
+
`entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
|
|
841
876
|
|
|
842
|
-
> **The record arrives as a single-element array under the
|
|
877
|
+
> **The record arrives as a single-element array under the query key** — `content.data.recent[0]`, not `content.data.article`. The runtime never coerces it to an object and never synthesizes a singular key. See *Data* in Part 4.
|
|
843
878
|
|
|
844
|
-
**Two options for bigger
|
|
879
|
+
**Two options for bigger sets:**
|
|
845
880
|
|
|
846
|
-
`deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote
|
|
881
|
+
`deferred: [body]` strips heavy fields from the list payload — cards stay light, while a `[slug]` page still receives the full record automatically and other components fetch on demand via `useEntityDetail`. For a remote source, add `detailUrl: /api/articles/{slug}` so the framework knows how to fetch one full record; file-based records emit per-record files at `/data/<name>/<slug>.json` and need no configuration.
|
|
847
882
|
|
|
848
883
|
`queryable:` declares which fields a reader may filter on, with enough metadata for the foundation to render controls:
|
|
849
884
|
|
|
850
885
|
```yaml
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
886
|
+
# queries.yml
|
|
887
|
+
members:
|
|
888
|
+
schema: '@std/person'
|
|
889
|
+
queryable:
|
|
890
|
+
department: { type: enum, label: Department, options: [biology, physics, chemistry] }
|
|
891
|
+
tenured: { type: boolean, label: Tenured }
|
|
892
|
+
start_year: { type: range, label: Start year, min: 1800, max: 2025 }
|
|
858
893
|
```
|
|
859
894
|
|
|
860
895
|
The site declares the *surface*; the foundation reads the metadata, renders matching controls (dropdown, toggle, slider), and composes the predicate when the reader picks values.
|
|
@@ -1745,13 +1780,13 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
|
|
|
1745
1780
|
```yaml
|
|
1746
1781
|
# pages/blog/page.yml
|
|
1747
1782
|
fetch:
|
|
1748
|
-
|
|
1783
|
+
query: recent
|
|
1749
1784
|
where: { published: true, tags: featured }
|
|
1750
1785
|
sort: date desc
|
|
1751
1786
|
limit: 3
|
|
1752
1787
|
```
|
|
1753
1788
|
|
|
1754
|
-
**Lean lists with `deferred:`.**
|
|
1789
|
+
**Lean lists with `deferred:`.** A query over records with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based records) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`. The hook is safe to call on any query: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
|
|
1755
1790
|
|
|
1756
1791
|
**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`, `useEntityDetail`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
|
|
1757
1792
|
|
|
@@ -1766,7 +1801,7 @@ A site isn't limited to file-based collections. The `fetcher:` block in `site.ym
|
|
|
1766
1801
|
fetcher:
|
|
1767
1802
|
baseUrl: https://api.example.com
|
|
1768
1803
|
headers: { X-Tenant: acme }
|
|
1769
|
-
envelope: {
|
|
1804
|
+
envelope: { list: data.items, item: data.article, error: errors.0.message }
|
|
1770
1805
|
|
|
1771
1806
|
supports: [where, limit, sort] # which operators the source evaluates natively
|
|
1772
1807
|
|
|
@@ -2383,12 +2418,12 @@ Running `extract` before a build is the usual first mistake — it reads the com
|
|
|
2383
2418
|
|
|
2384
2419
|
```
|
|
2385
2420
|
locales/freeform/es/pages/about/hero.md # by page route
|
|
2386
|
-
locales/freeform/es/
|
|
2421
|
+
locales/freeform/es/entities/article/x.md # records work too
|
|
2387
2422
|
```
|
|
2388
2423
|
|
|
2389
2424
|
These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
|
|
2390
2425
|
|
|
2391
|
-
**
|
|
2426
|
+
**Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it.
|
|
2392
2427
|
|
|
2393
2428
|
**Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
|
|
2394
2429
|
|
package/src/backend/client.js
CHANGED
|
@@ -105,34 +105,39 @@ export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* The fallback capability doc when `GET /dev/config`
|
|
109
|
-
*
|
|
110
|
-
*
|
|
108
|
+
* The fallback capability doc for when `GET /dev/config` was not asked for (no
|
|
109
|
+
* credential in hand) or did not answer. Keeps the client non-breaking.
|
|
110
|
+
*
|
|
111
|
+
* ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
|
|
112
|
+
* that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
|
|
113
|
+
* unknown reads falsy, and the caller stays silent rather than claiming a deployment
|
|
114
|
+
* does or does not charge. Every other key that used to sit here had no reader.
|
|
115
|
+
*
|
|
116
|
+
* ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
|
|
117
|
+
* a reader waiting to happen, and it is how this file came to describe a client that
|
|
118
|
+
* discovered its backend's gateway base, asset base and login path — none of which was
|
|
119
|
+
* ever true. The removals, and why each was not merely unused but wrong:
|
|
120
|
+
*
|
|
121
|
+
* `gatewayBase` sat here UNREAD until 2026-07-29. A serve location is read from the
|
|
122
|
+
* response that carries it (an upload plan's `serve_base`, an asset
|
|
123
|
+
* entry's `serve_url`, a payload's `config.base`) — never from a
|
|
124
|
+
* handshake, which cannot know a per-response answer.
|
|
125
|
+
* `assetBase` until 2026-08-17: one production host, hardcoded, applied to every
|
|
126
|
+
* deployment the CLI can be pointed at. Read only to compose an asset
|
|
127
|
+
* URL the plan already returns verbatim. Reader and composer both gone.
|
|
128
|
+
* `runtime` until 2026-08-22. A backend does not hold runtimes — a version comes
|
|
129
|
+
* from a CDN — so there is no installed set to report. What a site gets
|
|
130
|
+
* follows from its foundation's floor (`info.runtime`, at register).
|
|
131
|
+
* `auth` `loginPath` was never read: the login path is a constant in
|
|
132
|
+
* `utils/registry-auth.js`, and the ORIGIN comes from the resolution
|
|
133
|
+
* ladder, so the CLI is never told where to log in — it is born knowing.
|
|
134
|
+
* `delivery` `deploy` and `broker` had no reader. `publish` had one, but it could
|
|
135
|
+
* never refuse: the backend sent a literal true for every deployment,
|
|
136
|
+
* so the gate read a constant. Removed on both sides 2026-08-30.
|
|
137
|
+
* `assets` `supported` had no reader; the asset lane reports its own capability
|
|
138
|
+
* through the upload plan it returns.
|
|
111
139
|
*/
|
|
112
|
-
export const DISCOVERY_DEFAULTS = {
|
|
113
|
-
// ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
|
|
114
|
-
// discovery or from a per-response field (an upload plan's `serve_base`, an
|
|
115
|
-
// asset entry's `serve_url`); nothing here reconstructs one, so a default is a
|
|
116
|
-
// route name with no consumer. (A `gatewayBase` entry lived here unread until
|
|
117
|
-
// 2026-07-29.)
|
|
118
|
-
//
|
|
119
|
-
// `assetBase: 'https://assets.uniweb.app/'` sat here until 2026-08-17 — one
|
|
120
|
-
// production host, hardcoded, applied to EVERY deployment the CLI can be
|
|
121
|
-
// pointed at. It was only ever read to compose an asset URL, which the plan
|
|
122
|
-
// already returns as `serve_url`; both the reader and the composer are gone.
|
|
123
|
-
//
|
|
124
|
-
// No `runtime` entry, and there must not be one. A backend does not hold
|
|
125
|
-
// runtimes: a version is acquired from a CDN — the official mirror, the
|
|
126
|
-
// distribution channel, or a local server — so there is no installed set for
|
|
127
|
-
// it to report and nothing here to default. `runtime.installed` lived here
|
|
128
|
-
// until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
|
|
129
|
-
// builds to a backend; both are gone. The runtime a site gets follows from
|
|
130
|
-
// its foundation's floor (`info.runtime`, stated at register), not from
|
|
131
|
-
// anything the CLI asks a backend about.
|
|
132
|
-
auth: { loginPath: '/dev/auth/login', required: true },
|
|
133
|
-
delivery: { deploy: true, publish: true, broker: 'self-serve' },
|
|
134
|
-
assets: { supported: false }
|
|
135
|
-
}
|
|
140
|
+
export const DISCOVERY_DEFAULTS = {}
|
|
136
141
|
|
|
137
142
|
export class BackendClient {
|
|
138
143
|
/**
|
|
@@ -250,18 +255,78 @@ export class BackendClient {
|
|
|
250
255
|
// ── Discovery ─────────────────────────────────────────────────────────────────
|
|
251
256
|
|
|
252
257
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
258
|
+
* The session bearer IF one can be had without asking — an explicit `--token`, an
|
|
259
|
+
* env var, or a stored unexpired session. Never prompts, never logs in, returns null
|
|
260
|
+
* instead. `token()` is the one that may block; this is for calls that want to be
|
|
261
|
+
* authenticated when possible but must not *cause* an authentication.
|
|
262
|
+
* @returns {Promise<string|null>}
|
|
263
|
+
*/
|
|
264
|
+
async _tokenIfAvailable() {
|
|
265
|
+
if (this._token) return this._token
|
|
266
|
+
// ⛔ The injected resolver must be honoured here too, not just in `token()`.
|
|
267
|
+
// `pull` and `clone` pass one (`deps.getToken`), so skipping it would treat a caller
|
|
268
|
+
// that supplies its own auth as unauthenticated — and, worse, fall through to the
|
|
269
|
+
// machine's stored session, quietly using a DIFFERENT credential than the caller
|
|
270
|
+
// asked for. A throwing resolver is "no token", never a failed command.
|
|
271
|
+
if (this._getToken) {
|
|
272
|
+
try {
|
|
273
|
+
return (await this._getToken()) || null
|
|
274
|
+
} catch {
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const stored = await readRegistryAuth()
|
|
280
|
+
if (stored?.token && !isExpired(stored)) return stored.token
|
|
281
|
+
} catch {
|
|
282
|
+
/* advisory — a missing or unreadable session is simply "no token" */
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* GET /dev/config — the capability/handshake document. Lazy + cached for the client's
|
|
289
|
+
* lifetime; a missing route, a 401, or any transport/parse error falls back to
|
|
290
|
+
* DISCOVERY_DEFAULTS, so this can never be the reason a command fails.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ **Authenticated, or not sent at all.** `/dev/*` is the CLI's lane and the CLI is
|
|
293
|
+
* an authenticated client, so this attaches the bearer when it has one and **makes no
|
|
294
|
+
* request when it does not** — which keeps that true by construction rather than by
|
|
295
|
+
* ordering luck, and makes the route moving behind auth a no-op here.
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ It uses `_tokenIfAvailable()` and never `token()`, deliberately: **discovery must
|
|
298
|
+
* never be the thing that triggers a login.** A capability probe that opens a password
|
|
299
|
+
* prompt would be a worse defect than the anonymous call it replaced.
|
|
300
|
+
*
|
|
301
|
+
* ⛔ **Most of this document is deliberately not read.** `gatewayBase` and `assetBase`
|
|
302
|
+
* were dropped because a serve location is read from the response that carries it
|
|
303
|
+
* (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
|
|
304
|
+
* is not read either — the login path is a hardcoded constant
|
|
305
|
+
* (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
|
|
306
|
+
* `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
|
|
307
|
+
* rest: each one would be a second place a backend's layout is pinned.
|
|
308
|
+
*
|
|
259
309
|
* @returns {Promise<object>}
|
|
260
310
|
*/
|
|
261
311
|
async discover() {
|
|
262
312
|
if (this._discovery) return this._discovery
|
|
313
|
+
const bearer = await this._tokenIfAvailable()
|
|
314
|
+
|
|
315
|
+
// ⛔ NO CREDENTIAL ⇒ NO REQUEST. This is the rule made structural rather than
|
|
316
|
+
// incidental: apart from the login routes themselves, the CLI does not touch
|
|
317
|
+
// `/dev/*` without a bearer. The defaults are the honest answer here — we do not
|
|
318
|
+
// know this backend's capabilities and are not entitled to ask yet — and every
|
|
319
|
+
// caller already treats them as non-breaking, so nothing downstream changes.
|
|
320
|
+
if (!bearer) {
|
|
321
|
+
this._discovery = { ...DISCOVERY_DEFAULTS }
|
|
322
|
+
return this._discovery
|
|
323
|
+
}
|
|
324
|
+
|
|
263
325
|
try {
|
|
264
|
-
const res = await this.request('/dev/config', {
|
|
326
|
+
const res = await this.request('/dev/config', {
|
|
327
|
+
auth: false,
|
|
328
|
+
headers: { Authorization: `Bearer ${bearer}` }
|
|
329
|
+
})
|
|
265
330
|
this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
|
|
266
331
|
} catch {
|
|
267
332
|
this._discovery = { ...DISCOVERY_DEFAULTS }
|
|
@@ -519,7 +584,29 @@ export class BackendClient {
|
|
|
519
584
|
* shipped backend-side — collab backend-framework-b220):
|
|
520
585
|
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
521
586
|
* `draft_dirty` = never-published, or the synced draft changed since the last
|
|
522
|
-
* publish ("pushed but not published").
|
|
587
|
+
* publish ("pushed but not published").
|
|
588
|
+
*
|
|
589
|
+
* ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
|
|
590
|
+
* it yet** (shipped 2026-08-29; documented here so it is not lost twice):
|
|
591
|
+
*
|
|
592
|
+
* last_published_url · last_published_foundation · last_published_extensions
|
|
593
|
+
* last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
|
|
594
|
+
*
|
|
595
|
+
* Three things about it that a reader will otherwise get wrong:
|
|
596
|
+
*
|
|
597
|
+
* ⛔ `runtime_resolution` is `resolved` or `pinned:<reason>` (`operator` / `unknown_floor` /
|
|
598
|
+
* `no_foundations`). **A pin is a first-class answer, not a failure** — most sites are pinned
|
|
599
|
+
* at any moment, and an "old" runtime still satisfies the site's floor. Never surface
|
|
600
|
+
* `pinned:*` as an error state.
|
|
601
|
+
* ⛔ `extensions` is there because a site's code surface is the primary foundation **plus N
|
|
602
|
+
* extensions**; reading the primary alone describes a site nobody has.
|
|
603
|
+
* ⛔ `last_*` is deliberate on every one. `unpublish` LEAVES the URL populated (the static site
|
|
604
|
+
* may still be reachable), so beside `published: bool` a bare `published_url` would read as a
|
|
605
|
+
* liveness claim and be wrong exactly when it matters. And **nothing back-fills** — a site
|
|
606
|
+
* published before this reports them absent, which means "published before we recorded it",
|
|
607
|
+
* never "has no foundation".
|
|
608
|
+
*
|
|
609
|
+
* The path is VERB-FIRST (`status/{uuid}`)
|
|
523
610
|
* to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
|
|
524
611
|
* not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
|
|
525
612
|
* 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
|
package/src/backend/site-sync.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
computeUnitHashes,
|
|
28
28
|
collectUnitUuids,
|
|
29
29
|
collectFolderItemUuids,
|
|
30
|
-
|
|
30
|
+
collectQueryUuids,
|
|
31
31
|
readAssetMap
|
|
32
32
|
} from '@uniweb/build/uwx'
|
|
33
33
|
|
|
@@ -242,7 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
242
242
|
const prior = readSyncCacheFile(siteDir)
|
|
243
243
|
const dropped = [
|
|
244
244
|
'itemUuids',
|
|
245
|
-
'
|
|
245
|
+
'queryUuids',
|
|
246
246
|
'hashes',
|
|
247
247
|
'baseVersions',
|
|
248
248
|
'unitBases',
|
|
@@ -252,7 +252,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
252
252
|
itemUuids: {},
|
|
253
253
|
// Remote-derived exactly like itemUuids — it holds the OLD site's collection
|
|
254
254
|
// ids, and surviving the drop it would offer them for the new site's sections.
|
|
255
|
-
|
|
255
|
+
queryUuids: {},
|
|
256
256
|
hashes: {},
|
|
257
257
|
baseVersions: {},
|
|
258
258
|
unitBases: {},
|
|
@@ -492,12 +492,12 @@ export function readFolderItemUuids(siteDir) {
|
|
|
492
492
|
* its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
|
|
493
493
|
* handle the backend skips on parse and never stores.
|
|
494
494
|
*/
|
|
495
|
-
export function
|
|
496
|
-
return readMap(siteDir, '
|
|
495
|
+
export function readQueryUuids(siteDir) {
|
|
496
|
+
return readMap(siteDir, 'queryUuids')
|
|
497
497
|
}
|
|
498
|
-
export function
|
|
498
|
+
export function writeQueryUuids(siteDir, map) {
|
|
499
499
|
if (!map || !Object.keys(map).length) return
|
|
500
|
-
updateSyncCache(siteDir, {
|
|
500
|
+
updateSyncCache(siteDir, { queryUuids: map })
|
|
501
501
|
}
|
|
502
502
|
export function writeFolderItemUuids(siteDir, map) {
|
|
503
503
|
if (!map || !Object.keys(map).length) return
|
|
@@ -859,28 +859,27 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
|
|
|
859
859
|
: `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
|
|
860
860
|
)
|
|
861
861
|
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
862
|
+
// What the create echoed about this site's OWNER, and nothing beyond it.
|
|
863
|
+
//
|
|
864
|
+
// `hosts_free` is a property of the SCOPE — is this owner exempt? — and it is the only
|
|
865
|
+
// billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
|
|
866
|
+
// from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
|
|
867
|
+
// every deployment charges, so it read true everywhere and the join was testing a
|
|
868
|
+
// constant.
|
|
869
|
+
//
|
|
870
|
+
// ⛔ AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
|
|
871
|
+
// alone, which is the exact thing the join existed to prevent. Whether a given publish
|
|
872
|
+
// is charged is derived per-site at publish time, on a side the CLI cannot see, so any
|
|
873
|
+
// prediction made here can only be approximately right and would go stale silently.
|
|
874
|
+
// The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
|
|
875
|
+
// arrives when it matters; `backend/payment-handoff.js` already turns it into a
|
|
876
|
+
// checkout. Deciding whether payment is due is not the CLI's job.
|
|
877
|
+
//
|
|
878
|
+
// ⇒ What survives is the reassuring direction only, and only when it was stated:
|
|
879
|
+
// `false` is an answer we deliberately do not speak to, and missing is not an answer
|
|
880
|
+
// at all. Do not reintroduce a "you will be charged" line here.
|
|
881
|
+
if (payload?.hosts_free === true) {
|
|
879
882
|
note?.('This owner is hosted free — publishing will not require a subscription.')
|
|
880
|
-
} else if (enforces) {
|
|
881
|
-
note?.(
|
|
882
|
-
'Publishing this site live will require a hosting subscription on this backend.'
|
|
883
|
-
)
|
|
884
883
|
}
|
|
885
884
|
return org
|
|
886
885
|
}
|
|
@@ -968,12 +967,12 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
968
967
|
//
|
|
969
968
|
// ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
|
|
970
969
|
// a foundation-relative `@/member` into the `@org/member` the push shipped and
|
|
971
|
-
// keyed its hashes by. Without it the emit does not fail — `
|
|
970
|
+
// keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
|
|
972
971
|
// WARNS and ships the model unresolved, deliberately, so an org-less export still
|
|
973
972
|
// works — so every record of a `@/`-scoped collection is emitted under a key that
|
|
974
973
|
// can never match its banked one, and reads as changed forever.
|
|
975
974
|
//
|
|
976
|
-
// ⚠️ It hides in plain sight because `@std/…`
|
|
975
|
+
// ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
|
|
977
976
|
// scope is already absolute, so they match. A site mixing both — the marketing
|
|
978
977
|
// fixture has `@std/person` AND `@proximify/member` — shows some records settling
|
|
979
978
|
// and others never settling, which reads like a content problem rather than a
|
|
@@ -981,7 +980,7 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
981
980
|
// immediately after a successful push; passing the org took it to 1.
|
|
982
981
|
const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
|
|
983
982
|
const changed =
|
|
984
|
-
(pkg.siteContent?.entityCount || 0) + (pkg.
|
|
983
|
+
(pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
|
|
985
984
|
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
986
985
|
}
|
|
987
986
|
|
|
@@ -1008,13 +1007,13 @@ async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {
|
|
|
1008
1007
|
const applied = readAppliedInjections(siteDir)
|
|
1009
1008
|
const assetIds = readAssetMap(siteDir)
|
|
1010
1009
|
const org = readSiteOrg(siteDir)
|
|
1011
|
-
const
|
|
1010
|
+
const queryUuids = readQueryUuids(siteDir)
|
|
1012
1011
|
return emitSyncPackages(siteDir, {
|
|
1013
1012
|
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
1014
1013
|
priorHashes,
|
|
1015
1014
|
sendAll,
|
|
1016
1015
|
...applied,
|
|
1017
|
-
...(Object.keys(
|
|
1016
|
+
...(Object.keys(queryUuids).length ? { queryUuids } : {}),
|
|
1018
1017
|
...(Object.keys(assetIds).length ? { assetIds } : {}),
|
|
1019
1018
|
...(org ? { org } : {})
|
|
1020
1019
|
})
|
|
@@ -1069,7 +1068,7 @@ export async function pushSyncPackages({
|
|
|
1069
1068
|
asOrg,
|
|
1070
1069
|
report
|
|
1071
1070
|
}) {
|
|
1072
|
-
const { siteContent,
|
|
1071
|
+
const { siteContent, records, siteContentUuid, hashes, applied } = pkg
|
|
1073
1072
|
const { info, note, error } = report
|
|
1074
1073
|
const dim = report.dim || ((s) => s)
|
|
1075
1074
|
|
|
@@ -1272,8 +1271,8 @@ export async function pushSyncPackages({
|
|
|
1272
1271
|
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
1273
1272
|
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
1274
1273
|
note(
|
|
1275
|
-
"This site's
|
|
1276
|
-
'in place — e.g. adding or removing a schema-backed
|
|
1274
|
+
"This site's record structure is already established on the backend and can't be changed " +
|
|
1275
|
+
'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
|
|
1277
1276
|
'static (data-bundle) and schema-backed delivery. To change it: delete the deployed site and ' +
|
|
1278
1277
|
'redeploy, or clear `$uuid` in site.yml to deploy a fresh one.'
|
|
1279
1278
|
)
|
|
@@ -1383,8 +1382,8 @@ export async function pushSyncPackages({
|
|
|
1383
1382
|
// unique. Without it every push after the first re-sends the whole
|
|
1384
1383
|
// `collections` section uuid-less and is refused.
|
|
1385
1384
|
if (siteFinalizedDoc) {
|
|
1386
|
-
const
|
|
1387
|
-
if (Object.keys(
|
|
1385
|
+
const recordIds = collectQueryUuids(siteFinalizedDoc)
|
|
1386
|
+
if (Object.keys(recordIds).length) writeQueryUuids(siteDir, recordIds)
|
|
1388
1387
|
}
|
|
1389
1388
|
finalizedTotal += finalized.length
|
|
1390
1389
|
} else {
|
|
@@ -1429,16 +1428,16 @@ export async function pushSyncPackages({
|
|
|
1429
1428
|
// site-content uuid. On a brand-new site the backend creates the folder on this first
|
|
1430
1429
|
// push. Records round-trip their own $uuid (back-filled into source files); the folder
|
|
1431
1430
|
// itself has no uuid (the backend owns it).
|
|
1432
|
-
if (
|
|
1431
|
+
if (records) {
|
|
1433
1432
|
if (!boundSiteUuid) {
|
|
1434
1433
|
error(
|
|
1435
|
-
'Cannot push
|
|
1434
|
+
'Cannot push records — the site has no uuid yet. Push the site-content lane first.'
|
|
1436
1435
|
)
|
|
1437
1436
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1438
1437
|
}
|
|
1439
1438
|
const finalized = await pushLane(
|
|
1440
|
-
'
|
|
1441
|
-
() => client.pushFolder(boundSiteUuid,
|
|
1439
|
+
'records',
|
|
1440
|
+
() => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
|
|
1442
1441
|
undefined,
|
|
1443
1442
|
{ boundUuid: boundSiteUuid }
|
|
1444
1443
|
)
|
|
@@ -1447,7 +1446,7 @@ export async function pushSyncPackages({
|
|
|
1447
1446
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1448
1447
|
}
|
|
1449
1448
|
harvest(finalized)
|
|
1450
|
-
const bf = backfillEntityUuids({ index:
|
|
1449
|
+
const bf = backfillEntityUuids({ index: records.index, finalized })
|
|
1451
1450
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1452
1451
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1453
1452
|
if (bf.updated.length)
|