uniweb 0.59.2 → 0.61.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 +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
- package/templates/site/site.yml.hbs +11 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.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/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "^0.29.7",
|
|
45
|
+
"@uniweb/kit": "^0.19.4",
|
|
46
|
+
"@uniweb/schemas": "^0.3.3",
|
|
47
47
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
48
|
+
"@uniweb/runtime": "^0.27.3",
|
|
49
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/build": "^0.
|
|
52
|
+
"@uniweb/build": "^0.57.0",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -845,26 +845,26 @@ It is a small database, not a page tree. Three things, deliberately separate:
|
|
|
845
845
|
```
|
|
846
846
|
site/
|
|
847
847
|
├── pages/
|
|
848
|
-
├──
|
|
849
|
-
│ ├── article/
|
|
848
|
+
├── records/ # your site's records — every file here is one
|
|
849
|
+
│ ├── article/ # the folder names their data schema
|
|
850
850
|
│ │ ├── getting-started.md
|
|
851
851
|
│ │ └── design-tips.md
|
|
852
852
|
│ └── person/
|
|
853
853
|
│ └── alice.yml
|
|
854
|
-
├── records.yml #
|
|
854
|
+
├── records.yml # optional: sorts records into folders
|
|
855
855
|
├── queries.yml # how content is REACHED
|
|
856
856
|
└── site.yml
|
|
857
857
|
```
|
|
858
858
|
|
|
859
|
-
**`
|
|
859
|
+
**`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
|
|
860
860
|
|
|
861
861
|
| on disk | schema |
|
|
862
862
|
|---|---|
|
|
863
|
-
| `
|
|
864
|
-
| `
|
|
865
|
-
| `
|
|
863
|
+
| `records/article/…` | `@/article` — your foundation's own |
|
|
864
|
+
| `records/std/person/…` | `@std/person` — the shared standard set |
|
|
865
|
+
| `records/acme/project/…` | `@acme/project` — an org's |
|
|
866
866
|
|
|
867
|
-
Keep those folders flat: `
|
|
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.
|
|
868
868
|
|
|
869
869
|
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
870
870
|
|
|
@@ -877,32 +877,22 @@ Keep those folders flat: `entities/article/design-tips.md` works; `entities/arti
|
|
|
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`, `
|
|
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 `uniweb push` stops at it, naming it. (`published: false`, an older spelling, is refused.)
|
|
881
881
|
|
|
882
|
-
**`records.yml` —
|
|
882
|
+
**`records.yml` — optional, and only for folders.** Every record sits at the top of the site's records folder unless `records.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
|
-
- article/*.md
|
|
886
|
-
- person/*.yml
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
A bare string is a path under `entities/`, naming one file or matching many.
|
|
890
|
-
|
|
891
|
-
⚠️ **An empty `records.yml` is not the same as having none.** With no file, a build publishes every entity in `entities/`, and `uniweb push` leaves the published set alone. An empty file means "nothing is published": a build publishes none, and a push REMOVES what is published. The CLI asks before it does that.
|
|
892
|
-
|
|
893
|
-
**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:
|
|
894
|
-
|
|
895
|
-
```yaml
|
|
896
|
-
- article/2026-*.md
|
|
897
885
|
- folder: archive
|
|
898
886
|
label: The Archive
|
|
899
887
|
records:
|
|
900
888
|
- article/2025-*.md
|
|
901
889
|
```
|
|
902
890
|
|
|
903
|
-
A query reads one
|
|
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 `records.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
|
|
904
892
|
|
|
905
|
-
|
|
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
|
+
|
|
895
|
+
**`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
|
|
906
896
|
|
|
907
897
|
```yaml
|
|
908
898
|
recent:
|
|
@@ -912,7 +902,7 @@ recent:
|
|
|
912
902
|
|
|
913
903
|
team:
|
|
914
904
|
schema: '@std/person'
|
|
915
|
-
where: {
|
|
905
|
+
where: { active: true } # a predicate — see authoring/predicates.md
|
|
916
906
|
```
|
|
917
907
|
|
|
918
908
|
You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
|
|
@@ -944,7 +934,7 @@ pages/blog/
|
|
|
944
934
|
└── article.md # just `type: Article` — the record arrives automatically
|
|
945
935
|
```
|
|
946
936
|
|
|
947
|
-
`
|
|
937
|
+
`records/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.
|
|
948
938
|
|
|
949
939
|
**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.
|
|
950
940
|
|
|
@@ -1871,7 +1861,7 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
|
|
|
1871
1861
|
# pages/blog/page.yml
|
|
1872
1862
|
fetch:
|
|
1873
1863
|
query: recent
|
|
1874
|
-
where: {
|
|
1864
|
+
where: { tags: featured }
|
|
1875
1865
|
sort: date desc
|
|
1876
1866
|
limit: 3
|
|
1877
1867
|
```
|
|
@@ -2526,7 +2516,7 @@ uniweb push / pull / clone / status # Git-style content sync with the Uniweb b
|
|
|
2526
2516
|
uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
|
|
2527
2517
|
uniweb push --org @acme # First push/publish of a site: who owns it (see below)
|
|
2528
2518
|
uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
|
|
2529
|
-
uniweb login / logout #
|
|
2519
|
+
uniweb login / logout # One backend at a time: log in (--backend <url>) or out
|
|
2530
2520
|
uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
|
|
2531
2521
|
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
2532
2522
|
|
|
@@ -2560,7 +2550,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
|
|
|
2560
2550
|
| Any static host at all | `uniweb export` | full control; no Uniweb account needed |
|
|
2561
2551
|
| Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
|
|
2562
2552
|
|
|
2563
|
-
`uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `
|
|
2553
|
+
`uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
|
|
2564
2554
|
|
|
2565
2555
|
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
2566
2556
|
|
|
@@ -2582,7 +2572,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2582
2572
|
> uniweb publish --personal # your personal account, deliberately
|
|
2583
2573
|
> ```
|
|
2584
2574
|
>
|
|
2585
|
-
> The answer is recorded in `
|
|
2575
|
+
> The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
|
|
2586
2576
|
> not per machine. **Ask the human which one to use** rather than picking for them — a site in
|
|
2587
2577
|
> the wrong org cannot be moved from here. Sites that already exist are unaffected: their
|
|
2588
2578
|
> ownership is settled, so nothing is asked.
|
|
@@ -2598,11 +2588,13 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2598
2588
|
|
|
2599
2589
|
**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.
|
|
2600
2590
|
|
|
2591
|
+
**`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 --backend <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 --backend <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 `--backend` 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.
|
|
2592
|
+
|
|
2601
2593
|
**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.
|
|
2602
2594
|
|
|
2603
2595
|
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.
|
|
2604
2596
|
|
|
2605
|
-
**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
|
|
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`.
|
|
2606
2598
|
|
|
2607
2599
|
### Staying current
|
|
2608
2600
|
|
|
@@ -2695,7 +2687,7 @@ Running `extract` before a build is the usual first mistake — it reads the com
|
|
|
2695
2687
|
|
|
2696
2688
|
```
|
|
2697
2689
|
locales/freeform/es/pages/about/hero.md # by page route
|
|
2698
|
-
locales/freeform/es/
|
|
2690
|
+
locales/freeform/es/records/article/x.md # records work too
|
|
2699
2691
|
```
|
|
2700
2692
|
|
|
2701
2693
|
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.
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* ## Where an asset lands, and why there are two answers
|
|
9
9
|
*
|
|
10
|
-
* - **The map knows it** (`assets.json`) — the file goes
|
|
11
|
-
* author wrote. That is the whole reason the map is
|
|
12
|
-
* restores `public/images/hero.png`, not a hash.
|
|
10
|
+
* - **The map knows it** (this backend's `assets` in `sync.json`) — the file goes
|
|
11
|
+
* back to the path its author wrote. That is the whole reason the map is
|
|
12
|
+
* committed: a fresh clone restores `public/images/hero.png`, not a hash.
|
|
13
13
|
* - **The map does not know it** — this project has never held these bytes:
|
|
14
14
|
* authored in the app, or pushed from a machine whose map entry has not
|
|
15
15
|
* arrived. There is no local path to guess, so it lands at a **generic,
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* Identity rides beside the reference (`sync-package.js` stamps `assetId` next
|
|
22
22
|
* to the serve URL), so the address is already on the node. Composing one would
|
|
23
23
|
* mean holding the host's route layout — the coupling deleting `buildAssetUrl`
|
|
24
|
-
* removed from this CLI, and the reason
|
|
25
|
-
* id with no URL beside it is skipped, not guessed at.
|
|
24
|
+
* removed from this CLI, and the reason the asset map stores no URL either (only a
|
|
25
|
+
* fingerprint of one). An id with no URL beside it is skipped, not guessed at.
|
|
26
26
|
*
|
|
27
27
|
* ## ⛔ A failed download is a WARNING, never a failed pull
|
|
28
28
|
*
|
|
@@ -35,8 +35,9 @@ import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
|
|
|
35
35
|
import { dirname, join } from 'node:path'
|
|
36
36
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
37
37
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
readBackendState,
|
|
39
|
+
updateBackendMap,
|
|
40
|
+
carryServed,
|
|
40
41
|
servedFingerprint
|
|
41
42
|
} from '@uniweb/build/uwx'
|
|
42
43
|
|
|
@@ -116,7 +117,7 @@ export async function downloadMissingAssets({
|
|
|
116
117
|
}) {
|
|
117
118
|
const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
|
|
118
119
|
// Read first: a bare-string reference is only recognizable through the map.
|
|
119
|
-
const map =
|
|
120
|
+
const map = readBackendState(siteDir, origin).assets || {}
|
|
120
121
|
const refs = collectAssetRefs(document, map)
|
|
121
122
|
const out = { downloaded: [], present: [], failed: [], skipped: [] }
|
|
122
123
|
if (!refs.length) return out
|
|
@@ -166,6 +167,7 @@ export async function downloadMissingAssets({
|
|
|
166
167
|
|
|
167
168
|
// Newly-landed assets become known ones, so the next pull restores them to
|
|
168
169
|
// this path rather than fetching them again to a second location.
|
|
169
|
-
if (Object.keys(learned).length)
|
|
170
|
+
if (Object.keys(learned).length)
|
|
171
|
+
updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
|
|
170
172
|
return out
|
|
171
173
|
}
|
package/src/backend/client.js
CHANGED
|
@@ -42,65 +42,31 @@ import { uploadFoundationCode } from '../utils/code-upload.js'
|
|
|
42
42
|
import { uploadSiteAssets } from '../utils/asset-upload.js'
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Resolve the backend
|
|
46
|
-
* first). A full URL is reduced to its origin, so callers may pass a whole
|
|
47
|
-
* endpoint URL; an unparseable value falls through to the next tier.
|
|
45
|
+
* Resolve the backend a command talks to:
|
|
48
46
|
*
|
|
49
|
-
* 1.
|
|
50
|
-
* 2.
|
|
51
|
-
* 3.
|
|
52
|
-
*
|
|
53
|
-
* 5–7. the logged-in session origin > ~/.uniweb/config.json > the default
|
|
54
|
-
* (uniweb.app) — all via getRegistryApiBaseUrl()
|
|
47
|
+
* 1. UNIWEB_REGISTER_URL env — the override for automation (CI, scripts), one process
|
|
48
|
+
* 2. ⭐ the backend the user is LOGGED IN TO — the one session there is
|
|
49
|
+
* 3. the default backend — ~/.uniweb/config.json `registryApiUrl`, else uniweb.app —
|
|
50
|
+
* where the command's first request then asks the user to log in
|
|
55
51
|
*
|
|
56
|
-
* ⭐
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* project already answered.
|
|
52
|
+
* ⭐ **That is the whole ladder, for every command** *[Diego, 2026-09-21: "push and pull
|
|
53
|
+
* are also meant to go to the backend you are logged into" · "We do not allow any
|
|
54
|
+
* communication with backend if the user is not logged into a backend. The default
|
|
55
|
+
* backend for login, if not specified, is uniweb.app" · switching "via login to another
|
|
56
|
+
* backend is good, and the only way to switch"]*. Logging in is how a backend is chosen.
|
|
62
57
|
*
|
|
63
|
-
*
|
|
64
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
58
|
+
* ⛔ **No `--backend` tier, and no project tier.** The backend verbs had a per-command
|
|
59
|
+
* `--backend` until 2026-09-21 — it predates per-backend sessions, when one session
|
|
60
|
+
* slot made "aim this one command elsewhere" a flag's job; switching is a login now, and
|
|
61
|
+
* a script aims with UNIWEB_REGISTER_URL without touching the machine's login. A
|
|
62
|
+
* project's sync.json and deploy.yml routed commands too, until the same day.
|
|
63
|
+
* `--backend` survives only where it SELECTS rather than routes: `login` (where to log
|
|
64
|
+
* in) and `forget` (which backend's records to remove). `logout` needs none: there is
|
|
65
|
+
* one session.
|
|
67
66
|
*
|
|
68
|
-
* ⚠️ The explicit overrides stay ON TOP deliberately. `--backend` and the env var are how
|
|
69
|
-
* you deliberately aim elsewhere — at a staging mirror, say — and a project file must not
|
|
70
|
-
* be able to veto a flag the user just typed. A wrong aim is then caught by the guard
|
|
71
|
-
* rather than silently redirected.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} [flag] - the raw value of --backend, if supplied
|
|
74
|
-
* @param {object} [opts]
|
|
75
|
-
* @param {string} [opts.siteScope] - the project's `site.yml::$backend`
|
|
76
|
-
* @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
|
|
77
67
|
* @returns {string} a bare origin with no trailing slash
|
|
78
68
|
*/
|
|
79
|
-
export function resolveBackendOrigin(
|
|
80
|
-
const norm = (v) => {
|
|
81
|
-
try {
|
|
82
|
-
return new URL(v).origin
|
|
83
|
-
} catch {
|
|
84
|
-
return null
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (flag) {
|
|
88
|
-
const o = norm(flag)
|
|
89
|
-
if (o) return o
|
|
90
|
-
}
|
|
91
|
-
const env = process.env.UNIWEB_REGISTER_URL
|
|
92
|
-
if (env) {
|
|
93
|
-
const o = norm(env)
|
|
94
|
-
if (o) return o
|
|
95
|
-
}
|
|
96
|
-
if (siteScope) {
|
|
97
|
-
const o = norm(siteScope)
|
|
98
|
-
if (o) return o
|
|
99
|
-
}
|
|
100
|
-
if (siteBackend) {
|
|
101
|
-
const o = norm(siteBackend)
|
|
102
|
-
if (o) return o
|
|
103
|
-
}
|
|
69
|
+
export function resolveBackendOrigin() {
|
|
104
70
|
return getRegistryApiBaseUrl()
|
|
105
71
|
}
|
|
106
72
|
|
|
@@ -142,10 +108,8 @@ export const DISCOVERY_DEFAULTS = {}
|
|
|
142
108
|
export class BackendClient {
|
|
143
109
|
/**
|
|
144
110
|
* @param {object} [opts]
|
|
145
|
-
* @param {string} [opts.origin] - explicit origin (wins over
|
|
146
|
-
*
|
|
147
|
-
* @param {string} [opts.siteScope] - the project's `site.yml::$backend` (site verbs)
|
|
148
|
-
* @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
|
|
111
|
+
* @param {string} [opts.origin] - explicit origin (wins over the ladder; tests and
|
|
112
|
+
* internal callers — no command takes one from the user)
|
|
149
113
|
* @param {string} [opts.token] - explicit bearer (wins over env + stored session)
|
|
150
114
|
* @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
|
|
151
115
|
* callers with their own auth); used when no explicit token/env is present
|
|
@@ -155,9 +119,6 @@ export class BackendClient {
|
|
|
155
119
|
*/
|
|
156
120
|
constructor({
|
|
157
121
|
origin,
|
|
158
|
-
originFlag,
|
|
159
|
-
siteScope,
|
|
160
|
-
siteBackend,
|
|
161
122
|
token,
|
|
162
123
|
getToken,
|
|
163
124
|
args = [],
|
|
@@ -165,7 +126,7 @@ export class BackendClient {
|
|
|
165
126
|
fetchImpl
|
|
166
127
|
} = {}) {
|
|
167
128
|
this.origin = (
|
|
168
|
-
origin || resolveBackendOrigin(
|
|
129
|
+
origin || resolveBackendOrigin()
|
|
169
130
|
).replace(/\/+$/, '')
|
|
170
131
|
this._token = token || process.env.UNIWEB_TOKEN || null
|
|
171
132
|
this._getToken = getToken || null
|
|
@@ -176,9 +137,9 @@ export class BackendClient {
|
|
|
176
137
|
}
|
|
177
138
|
|
|
178
139
|
/**
|
|
179
|
-
* The session bearer, resolved lazily and memoized. Order
|
|
180
|
-
*
|
|
181
|
-
* session > interactive login (ensureRegistryAuth).
|
|
140
|
+
* The session bearer, resolved lazily and memoized. Order: an explicit `token`
|
|
141
|
+
* (constructor — tests and internal callers; no command takes one from the user) /
|
|
142
|
+
* UNIWEB_TOKEN > the stored session > interactive login (ensureRegistryAuth).
|
|
182
143
|
* @returns {Promise<string>}
|
|
183
144
|
*/
|
|
184
145
|
async token() {
|
|
@@ -187,27 +148,13 @@ export class BackendClient {
|
|
|
187
148
|
this._token = await this._getToken()
|
|
188
149
|
return this._token
|
|
189
150
|
}
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
stored?.token &&
|
|
199
|
-
!isExpired(stored) &&
|
|
200
|
-
stored.origin &&
|
|
201
|
-
stored.origin.replace(/\/+$/, '') !== this.origin
|
|
202
|
-
) {
|
|
203
|
-
console.error(
|
|
204
|
-
`\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
/* advisory only */
|
|
209
|
-
}
|
|
210
|
-
}
|
|
151
|
+
// ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
|
|
152
|
+
// It existed because the session store held ONE record: a session for another
|
|
153
|
+
// backend was the only session, so it was about to be sent here and rejected, and a
|
|
154
|
+
// warning was the best available move. Sessions are keyed by origin now
|
|
155
|
+
// (utils/registry-auth.js), so being logged into another backend is just a fact
|
|
156
|
+
// about another backend — `ensureRegistryAuth` below finds this origin's session or
|
|
157
|
+
// asks for one. Warning about the other would be noise about nothing.
|
|
211
158
|
this._token = await ensureRegistryAuth({
|
|
212
159
|
apiBase: this.origin,
|
|
213
160
|
command: this._command,
|
|
@@ -255,8 +202,8 @@ export class BackendClient {
|
|
|
255
202
|
// ── Discovery ─────────────────────────────────────────────────────────────────
|
|
256
203
|
|
|
257
204
|
/**
|
|
258
|
-
* The session bearer IF one can be had without asking — an explicit
|
|
259
|
-
* env var, or a stored unexpired session. Never prompts, never logs in, returns null
|
|
205
|
+
* The session bearer IF one can be had without asking — an explicit `token`, the
|
|
206
|
+
* UNIWEB_TOKEN env var, or a stored unexpired session. Never prompts, never logs in, returns null
|
|
260
207
|
* instead. `token()` is the one that may block; this is for calls that want to be
|
|
261
208
|
* authenticated when possible but must not *cause* an authentication.
|
|
262
209
|
* @returns {Promise<string|null>}
|
|
@@ -276,7 +223,7 @@ export class BackendClient {
|
|
|
276
223
|
}
|
|
277
224
|
}
|
|
278
225
|
try {
|
|
279
|
-
const stored = await readRegistryAuth()
|
|
226
|
+
const stored = await readRegistryAuth(this.origin)
|
|
280
227
|
if (stored?.token && !isExpired(stored)) return stored.token
|
|
281
228
|
} catch {
|
|
282
229
|
/* advisory — a missing or unreadable session is simply "no token" */
|
|
@@ -455,8 +402,8 @@ export class BackendClient {
|
|
|
455
402
|
* the field itself is required and cannot be blank.
|
|
456
403
|
*
|
|
457
404
|
* NOT idempotent: two calls mint two sites (a name is not a unique key, by
|
|
458
|
-
* design). Callers must guard on
|
|
459
|
-
* immediately — see `ensureSiteExists`.
|
|
405
|
+
* design). Callers must guard on the site's uuid for this backend (sync.json) and
|
|
406
|
+
* write the result back immediately — see `ensureSiteExists`.
|
|
460
407
|
*
|
|
461
408
|
* @param {{ name: string, foundation: string, asOrg?: string|null }} opts
|
|
462
409
|
* @returns {Promise<Response>} `{ site_content_uuid }`
|
|
@@ -545,7 +492,7 @@ export class BackendClient {
|
|
|
545
492
|
/**
|
|
546
493
|
* POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
|
|
547
494
|
* backend state live; it does NOT push local files). `{uuid}` is the site-content
|
|
548
|
-
* uuid (
|
|
495
|
+
* uuid on this backend (sync.json); a never-synced site 404s (sync first, or use deploy).
|
|
549
496
|
* Languages, when present, go in the body; absent → no body. Returns the raw
|
|
550
497
|
* Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
|
|
551
498
|
*
|
|
@@ -38,8 +38,8 @@ import { execFileSync } from 'node:child_process'
|
|
|
38
38
|
|
|
39
39
|
import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
|
|
40
40
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
41
|
-
import { readFlagValue } from '../utils/args.js'
|
|
42
41
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
42
|
+
import { publishScope } from '../utils/registry-orgs.js'
|
|
43
43
|
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -127,13 +127,17 @@ export function resolveLocalExtensions(siteDir, siteYml) {
|
|
|
127
127
|
// as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
|
|
128
128
|
// the catalog does not have, so every push re-released it, and pinned the site
|
|
129
129
|
// to that same wrong name.
|
|
130
|
+
//
|
|
131
|
+
// ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
|
|
132
|
+
// may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
|
|
133
|
+
// bare one looked the foundation up as `acme/src` and pinned the site to that.
|
|
130
134
|
function foundationScopedName(dir) {
|
|
131
135
|
try {
|
|
132
136
|
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
133
137
|
const name = pkg?.uniweb?.id || pkg?.name
|
|
134
138
|
if (typeof name === 'string' && name.startsWith('@')) return name
|
|
135
|
-
const scope = pkg?.uniweb?.scope
|
|
136
|
-
if (scope && name) return `${
|
|
139
|
+
const scope = publishScope(pkg?.uniweb?.scope)
|
|
140
|
+
if (scope && name) return `${scope}/${name}`
|
|
137
141
|
return null
|
|
138
142
|
} catch {
|
|
139
143
|
return null
|
|
@@ -151,14 +155,12 @@ function readPkgField(dir, field) {
|
|
|
151
155
|
}
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
158
|
+
// What travels to the spawned `uniweb register` / `build` on the command line: only
|
|
159
|
+
// --non-interactive. The BACKEND travels in the child's environment
|
|
160
|
+
// (releaseFoundation), and the SESSION is the shared session file — or UNIWEB_TOKEN,
|
|
161
|
+
// which the child inherits. The backend commands take no `--backend` or `--token`.
|
|
156
162
|
function forwardedFlags(args) {
|
|
157
163
|
const out = []
|
|
158
|
-
for (const name of ['--backend', '--token']) {
|
|
159
|
-
const v = readFlagValue(args, name)
|
|
160
|
-
if (v) out.push(name, v)
|
|
161
|
-
}
|
|
162
164
|
if (isNonInteractive(args)) out.push('--non-interactive')
|
|
163
165
|
return out
|
|
164
166
|
}
|
|
@@ -322,7 +324,7 @@ async function bringLocalCodeAlong({
|
|
|
322
324
|
}
|
|
323
325
|
say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
|
|
324
326
|
return {
|
|
325
|
-
released: releaseFoundation(local, args, cliBin, say),
|
|
327
|
+
released: releaseFoundation(local, args, cliBin, say, client?.origin),
|
|
326
328
|
proceed: true,
|
|
327
329
|
ref: pinnedRef()
|
|
328
330
|
}
|
|
@@ -365,7 +367,7 @@ async function bringLocalCodeAlong({
|
|
|
365
367
|
: `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
|
|
366
368
|
)
|
|
367
369
|
return {
|
|
368
|
-
released: releaseFoundation(local, args, cliBin, say),
|
|
370
|
+
released: releaseFoundation(local, args, cliBin, say, client?.origin),
|
|
369
371
|
proceed: true,
|
|
370
372
|
ref: pinnedRef()
|
|
371
373
|
}
|
|
@@ -396,7 +398,7 @@ async function bringLocalCodeAlong({
|
|
|
396
398
|
)
|
|
397
399
|
if (reRelease)
|
|
398
400
|
return {
|
|
399
|
-
released: releaseFoundation(local, args, cliBin, say),
|
|
401
|
+
released: releaseFoundation(local, args, cliBin, say, client?.origin),
|
|
400
402
|
proceed: true,
|
|
401
403
|
ref: pinnedRef()
|
|
402
404
|
}
|
|
@@ -490,12 +492,15 @@ function buildFoundation(local, cliBin) {
|
|
|
490
492
|
// Release = `uniweb register` in the foundation directory (the one foundation
|
|
491
493
|
// release path). Returns true on success; throws to the caller on failure so
|
|
492
494
|
// publish stops before going live with missing code.
|
|
493
|
-
function releaseFoundation(local, args, cliBin, say) {
|
|
495
|
+
function releaseFoundation(local, args, cliBin, say, origin) {
|
|
494
496
|
console.log('')
|
|
497
|
+
// ⭐ Pinned to the parent's backend through the environment, so the release lands
|
|
498
|
+
// where the publish goes by construction — not because two processes happen to read
|
|
499
|
+
// the same session file the same way.
|
|
495
500
|
execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
|
|
496
501
|
cwd: local.dir,
|
|
497
502
|
stdio: 'inherit',
|
|
498
|
-
env: process.env
|
|
503
|
+
env: origin ? { ...process.env, UNIWEB_REGISTER_URL: origin } : process.env
|
|
499
504
|
})
|
|
500
505
|
console.log('')
|
|
501
506
|
return true
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
*
|
|
39
39
|
* ## ⛔ A HASH, never the block
|
|
40
40
|
*
|
|
41
|
-
* `$secrets`
|
|
41
|
+
* `$secrets` names every secret the site has (values are the `#ref` marker, never
|
|
42
|
+
* secret material — the old wording here said otherwise), and `$services[].config` is opaque and
|
|
42
43
|
* per-service — anything may be in it. `deploy.yml` is committed, so recording
|
|
43
44
|
* either verbatim would write them into git. Equality is all this gate needs;
|
|
44
45
|
* *what* differs is a question for the backend's own copy of the request, not for
|
|
@@ -60,7 +61,8 @@ import { createHash } from 'node:crypto'
|
|
|
60
61
|
* Rows are ordered by their serialized form and object keys sorted, so reordering
|
|
61
62
|
* rows or keys in the file is not a change. It is not a request to move a line.
|
|
62
63
|
*
|
|
63
|
-
* @param {*} declared - the raw `
|
|
64
|
+
* @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
|
|
65
|
+
* `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
|
|
64
66
|
* @returns {string|null} 16 hex chars, or null when undeclared
|
|
65
67
|
*/
|
|
66
68
|
export function fingerprintDeclaration(declared) {
|
|
@@ -91,11 +93,14 @@ function stableString(value) {
|
|
|
91
93
|
* @param {object} siteYml
|
|
92
94
|
* @returns {{servicesRequest?: string, secretsRequest?: string}}
|
|
93
95
|
*/
|
|
94
|
-
export function fingerprintRequest(siteYml) {
|
|
96
|
+
export function fingerprintRequest(siteYml, provisioned = {}) {
|
|
95
97
|
const out = {}
|
|
96
|
-
|
|
98
|
+
// ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
|
|
99
|
+
// backend being published to (they were `site.yml::$services` / `$secrets` until
|
|
100
|
+
// 2026-09-20). The language selection below is still authored, so still site.yml.
|
|
101
|
+
const services = fingerprintDeclaration(provisioned?.services)
|
|
97
102
|
if (services) out.servicesRequest = services
|
|
98
|
-
const secrets = fingerprintDeclaration(
|
|
103
|
+
const secrets = fingerprintDeclaration(provisioned?.secrets)
|
|
99
104
|
if (secrets) out.secretsRequest = secrets
|
|
100
105
|
// ⭐ The language selection is a request too, and it is the one that moves a
|
|
101
106
|
// PRICE — the line is billed on how many languages go out. Banking it is what
|
|
@@ -124,8 +129,8 @@ export function fingerprintRequest(siteYml) {
|
|
|
124
129
|
* @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
|
|
125
130
|
* @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
|
|
126
131
|
*/
|
|
127
|
-
export function decideDeclaration(siteYml, lastDeploy) {
|
|
128
|
-
const now = fingerprintRequest(siteYml)
|
|
132
|
+
export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
|
|
133
|
+
const now = fingerprintRequest(siteYml, provisioned)
|
|
129
134
|
if (!now.servicesRequest && !now.secretsRequest) {
|
|
130
135
|
// Nothing in the file to send. The gate is moot; say so rather than
|
|
131
136
|
// reporting "unchanged", which would imply a comparison happened.
|
|
@@ -178,8 +183,8 @@ export function decideDeclaration(siteYml, lastDeploy) {
|
|
|
178
183
|
* @param {object|null} lastDeploy - the banked base
|
|
179
184
|
* @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
|
|
180
185
|
*/
|
|
181
|
-
export function reconcileRequest(siteYml, remoteServices, lastDeploy) {
|
|
182
|
-
return reconcile(
|
|
186
|
+
export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
|
|
187
|
+
return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
/**
|
|
@@ -72,18 +72,18 @@ export async function uploadSiteMedia(
|
|
|
72
72
|
warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
|
|
73
73
|
|
|
74
74
|
const map = {}
|
|
75
|
-
// The identity half, for `
|
|
76
|
-
// `id`+`ext` for every entry INCLUDING a `present: true`
|
|
77
|
-
// re-push of unchanged media still records identity without
|
|
78
|
-
// which is what makes the committed map cheap to keep accurate.
|
|
75
|
+
// The identity half, for this backend's asset map in `sync.json`. The plan
|
|
76
|
+
// returns an authoritative `id`+`ext` for every entry INCLUDING a `present: true`
|
|
77
|
+
// dedup skip, so a re-push of unchanged media still records identity without
|
|
78
|
+
// moving bytes — which is what makes the committed map cheap to keep accurate.
|
|
79
79
|
const ids = {}
|
|
80
80
|
for (const ref of refs) {
|
|
81
81
|
const entry = result.assetsByLocalUrl[ref]
|
|
82
82
|
if (!entry) continue
|
|
83
83
|
// `served` — a fingerprint of the address, never the address. It is how a pull
|
|
84
84
|
// recognizes this asset where the reference is a bare string with no room for an
|
|
85
|
-
// id beside it (`info.preview`, `seo.image`, a section param);
|
|
86
|
-
//
|
|
85
|
+
// id beside it (`info.preview`, `seo.image`, a section param); the header of
|
|
86
|
+
// `@uniweb/build`'s asset-map.js says why a hash and not the URL.
|
|
87
87
|
if (entry.id) {
|
|
88
88
|
ids[ref] = {
|
|
89
89
|
id: entry.id,
|