uniweb 1.20.4 → 1.21.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 +5 -5
- package/partials/agents.md +24 -5
- package/partials/start.md +17 -0
- package/src/backend/app-changes.js +159 -0
- package/src/backend/client.js +3 -3
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/backend/service-request.js +21 -5
- package/src/commands/doctor.js +89 -10
- package/src/commands/pull.js +82 -9
- package/src/commands/register.js +15 -15
- package/src/commands/status.js +1020 -256
- package/src/framework-index.json +2 -2
- package/src/index.js +38 -2
- package/src/utils/code-upload.js +27 -27
- package/src/utils/flag-guard.js +17 -2
- package/src/utils/git.js +91 -1
- package/src/utils/status-next.js +47 -0
- package/src/utils/sync-point.js +20 -2
- package/src/utils/update-check.js +8 -6
- package/templates/site/site.yml.hbs +4 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.5.21",
|
|
44
44
|
"@uniweb/content-writer": "~1.3.1",
|
|
45
|
-
"@uniweb/core": "~1.4.7",
|
|
46
45
|
"@uniweb/schemas": "~1.6.5",
|
|
46
|
+
"@uniweb/core": "~1.4.7",
|
|
47
47
|
"@uniweb/semantic-parser": "~1.13.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.10.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "~1.10.3",
|
|
51
|
+
"@uniweb/semantic-parser": "~1.13.0",
|
|
52
|
+
"@uniweb/content-reader": "~1.5.7"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -33,15 +33,18 @@ You're building a *system* of section types, not individual pages. That's what m
|
|
|
33
33
|
| Existing project, component task (new section type, restyle) | **Part 2**, then Part 4 |
|
|
34
34
|
| Porting a design from React / Next / a generated site | **Part 1**, then Part 5 → *Migrating* |
|
|
35
35
|
|
|
36
|
-
###
|
|
36
|
+
### Four commands that replace guessing
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
+
uniweb status # what changed in the app, in git and in Uniweb — and what to run
|
|
39
40
|
uniweb inspect <path> # print the parsed content shape of a section or page
|
|
40
41
|
uniweb <command> --help # exact flags, always current — no side effects
|
|
41
42
|
uniweb doctor # diagnose project configuration (--fix to repair)
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
`uniweb inspect` is the important one. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
|
|
45
|
+
`uniweb inspect` is the important one for content. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
|
|
46
|
+
|
|
47
|
+
`uniweb status` is the one for a project other people edit. A site in the Uniweb app can change there at any time, teammates push to git, and Uniweb releases often. Run it when you begin, when you come back to the project, before you push, publish or deploy, and after a command refuses: it changes nothing, opens with what the site is — synced where, live where — and ends with the commands to run, in order (`--json` for a script).
|
|
45
48
|
|
|
46
49
|
### The four silent failures
|
|
47
50
|
|
|
@@ -189,6 +192,8 @@ The rest of this guide explains how Uniweb works. This part is what to do *first
|
|
|
189
192
|
|
|
190
193
|
### 1. Orient — what kind of workspace is this?
|
|
191
194
|
|
|
195
|
+
**Run `uniweb status` first.** It says whether the site changed in the app since this copy last synced, whether git or Uniweb has something newer, and what to run about each — before you edit something someone else just changed.
|
|
196
|
+
|
|
192
197
|
**There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
|
|
193
198
|
|
|
194
199
|
| Globs | Layout | What it implies |
|
|
@@ -2818,7 +2823,8 @@ uniweb publish # Ship to Uniweb hosting, foundation included
|
|
|
2818
2823
|
uniweb add ci --target foundation # Publish a foundation for free at permanent versioned URLs
|
|
2819
2824
|
# (GitHub Pages → foundations/<name>/<version>/entry.js)
|
|
2820
2825
|
|
|
2821
|
-
uniweb
|
|
2826
|
+
uniweb status # What changed in the app, in git and in Uniweb — and what to run
|
|
2827
|
+
uniweb push / pull / clone # Git-style content sync with the Uniweb backend
|
|
2822
2828
|
uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
|
|
2823
2829
|
uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
|
|
2824
2830
|
uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
|
|
@@ -2830,7 +2836,7 @@ uniweb rename <foundation|site|extension> <old> <new> # Rename across the whol
|
|
|
2830
2836
|
uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
|
|
2831
2837
|
uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
|
|
2832
2838
|
|
|
2833
|
-
uniweb -v #
|
|
2839
|
+
uniweb -v # CLI version — only a global install also says whether a newer one exists
|
|
2834
2840
|
uniweb doctor # Diagnose project configuration (--fix to auto-repair)
|
|
2835
2841
|
uniweb validate # Check file-based data against declared schemas (fails on a violation; --lax)
|
|
2836
2842
|
npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
|
|
@@ -2858,6 +2864,14 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
|
|
|
2858
2864
|
|
|
2859
2865
|
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
2860
2866
|
|
|
2867
|
+
**Three facts about a site, and they are independent — `uniweb status` opens with all three:**
|
|
2868
|
+
|
|
2869
|
+
- **Synced** — the project has a site on a backend, so authors can edit it in the Uniweb app. `sync.json` names it on each backend: `https://uniweb.app` by default, or a server of your own (`uniweb login --server <url>`).
|
|
2870
|
+
- **Published** — that backend serves it live: Uniweb Cloud, on `https://uniweb.app`. A published site is a synced site; a synced site need not be published — it can sit in the app as a draft.
|
|
2871
|
+
- **Deployed** — a build of it is on another host, such as GitHub Pages or S3: by CI on every push (`uniweb add ci`), or by hand (`uniweb deploy`). `deploy.yml` lists those hosts, and a bare `uniweb deploy` goes to its default target.
|
|
2872
|
+
|
|
2873
|
+
A site can be in more than one place — usually while it moves from one host to another. Before you ship, read where it is: `publish` makes a change live on Uniweb Cloud, `git push` on a CI-deployed host, and `uniweb deploy --target <name>` on one deployed by hand — and each of these is the person's to decide. `uniweb status` also lists the services the live site has (`services:` in `site.yml` is the request, never proof of what the site has).
|
|
2874
|
+
|
|
2861
2875
|
### Uniweb Cloud and the two-sided workflow
|
|
2862
2876
|
|
|
2863
2877
|
`uniweb publish` ships to Uniweb Cloud, and it's worth understanding what that buys, because it isn't only hosting — it's how a team of technical and non-technical people works on one site.
|
|
@@ -2896,6 +2910,8 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2896
2910
|
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
2897
2911
|
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
2898
2912
|
|
|
2913
|
+
**Working on a site others edit — run `uniweb status` when you begin, when you come back to it, before you push, publish or deploy, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file — as `uniweb pull` would write it, translations and records included; what you hold that it lacks; where the site is live, and what services it has; who has it open in the app right now, and on which file. Then it lists the commands to run, in an order that works from the top — `uniweb pull` before the push it would otherwise refuse. Three habits go with it: take what changed (`pull`, or `refresh`) before you edit; never `--force` past a refusal; and when someone has a page open in the app, tell the person before you change that page. A step marked *ask the person first* — going live, which may cost money — is theirs to decide.
|
|
2914
|
+
|
|
2899
2915
|
**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. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--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.
|
|
2900
2916
|
|
|
2901
2917
|
**`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`. In a git repository the push commits 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.
|
|
@@ -2942,12 +2958,15 @@ Either side can publish. Nothing about this changes how you build: the same foun
|
|
|
2942
2958
|
### Staying current
|
|
2943
2959
|
|
|
2944
2960
|
```bash
|
|
2945
|
-
uniweb
|
|
2961
|
+
uniweb status # among its checks: whether a newer Uniweb is out than this project runs
|
|
2962
|
+
uniweb -v # CLI version — only a global install also says whether a newer one exists
|
|
2946
2963
|
uniweb doctor # report drift in this project, changing nothing
|
|
2947
2964
|
npx uniweb@latest update --dry-run # preview exactly what update would change
|
|
2948
2965
|
npx uniweb@latest update # apply: align @uniweb/* deps AND refresh AGENTS.md
|
|
2949
2966
|
```
|
|
2950
2967
|
|
|
2968
|
+
When `status` names a newer release, take it: `npx uniweb@latest update`, then build and look at the pages. Releases rarely ask anything of a project.
|
|
2969
|
+
|
|
2951
2970
|
**`npx uniweb@latest update` is the command for bringing a project up to date.** It aligns the project's `@uniweb/*` dependencies *and* this AGENTS.md to the version matrix of the CLI that runs it. Deps and documentation move together — that's the whole point of the verb.
|
|
2952
2971
|
|
|
2953
2972
|
**What the version numbers mean.** A patch brings fixes and features; a minor version is where Uniweb puts breaking changes — `update` lists each package that crosses one, so build and check the project before shipping — and a major is kept for something really big. Dependencies are written as tilde ranges (`~1.0.1`), so `pnpm install` and `pnpm update` never cross a minor on their own. The move from 0.x to 1.0.1 asks nothing of a project that was on the latest 0.x versions — 1.0.1 carried every package's 0.x line over — though `update` marks each package `major · BREAKING`.
|
package/partials/start.md
CHANGED
|
@@ -60,6 +60,19 @@ which parts your task needs.
|
|
|
60
60
|
|
|
61
61
|
Uniweb's mistakes are quiet: an unknown section type or param builds fine and renders
|
|
62
62
|
wrong. AGENTS.md says how to check.
|
|
63
|
+
{{#if project}}
|
|
64
|
+
|
|
65
|
+
## Check what changed
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx uniweb status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The site may have been edited in the Uniweb app since this copy last synced, by the
|
|
72
|
+
person or someone they work with, and Uniweb may have a newer release. `status` says so,
|
|
73
|
+
changes nothing, and ends with the commands to run, in order. A step it marks "ask the
|
|
74
|
+
person first" is theirs to decide.
|
|
75
|
+
{{/if}}
|
|
63
76
|
|
|
64
77
|
## Install and run
|
|
65
78
|
|
|
@@ -103,6 +116,10 @@ If the site needs paying for, `publish` prints an address instead of going live.
|
|
|
103
116
|
it for the person, tell them it's where they pay, and run `publish` again once they're
|
|
104
117
|
done.
|
|
105
118
|
|
|
119
|
+
Once the site is in the app, it can be edited there at any time. Before you change it
|
|
120
|
+
again, and before each push, run `npx uniweb status`: it says what changed in the app
|
|
121
|
+
since your last push or pull, and what to run.
|
|
122
|
+
|
|
106
123
|
## More
|
|
107
124
|
|
|
108
125
|
- Documentation index: https://uniweb.io/llms.txt
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed on the backend since this copy last synced — READ, never taken.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE SECOND ANSWER. In a repository `uniweb status` asks the pull itself
|
|
5
|
+
* (`commands/pull.js::probePull`), which names every file a pull would change. This answers
|
|
6
|
+
* where that cannot — outside git, or with no sync point to merge from — by units and
|
|
7
|
+
* records, not files: a section's translations, which a pull writes to `locales/*.json`,
|
|
8
|
+
* travel inside the section here (measured 2026-10-26: 8 units named, 11 files pulled).
|
|
9
|
+
*
|
|
10
|
+
* `uniweb pull` takes the backend's content; `uniweb status` asks what there is to take.
|
|
11
|
+
* The two must agree on what "changed" means, so this reads the backend as a pull does —
|
|
12
|
+
* the same two lanes, the same conditional GET (`etagsHold`) — and compares the answer
|
|
13
|
+
* with what this copy recorded at its last sync, each in the backend's own representation:
|
|
14
|
+
*
|
|
15
|
+
* - the site-content entity's version against `baseVersions`: nothing moved, said
|
|
16
|
+
* precisely;
|
|
17
|
+
* - each unit's hash against `unitBases.remote`, which a pull and a push both bank from
|
|
18
|
+
* the backend's own copy: which files moved. A unit's hash is only ever compared with a
|
|
19
|
+
* base in its own representation (`@uniweb/build`'s `site-diff.js`);
|
|
20
|
+
* - each record's version against `baseVersions`: which records moved.
|
|
21
|
+
*
|
|
22
|
+
* Whoever made the change — an author in the app, or a push from another copy — it is the
|
|
23
|
+
* backend's content this copy does not hold yet.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ IT TAKES NOTHING. An ETag, a version or a hash banked here would say this copy holds
|
|
26
|
+
* content it never took, and the next pull would hear 304 and never catch up — the failure
|
|
27
|
+
* `pull.js` records beside `PULL_CACHE_VERSION`. Nothing here writes a file.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { computeUnitHashes, documentSchema, indexFolder, readBackendState } from '@uniweb/build/uwx'
|
|
31
|
+
import {
|
|
32
|
+
etagsHold,
|
|
33
|
+
readPullCache,
|
|
34
|
+
readPullDocuments,
|
|
35
|
+
readPullVersions,
|
|
36
|
+
splitRecordsPull
|
|
37
|
+
} from '../commands/pull.js'
|
|
38
|
+
import { readBaseVersions, readUnitBases } from './site-sync.js'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} p
|
|
42
|
+
* @param {import('./client.js').BackendClient} p.client - signed in; never prompts here
|
|
43
|
+
* @param {string} p.siteDir
|
|
44
|
+
* @param {string} p.siteUuid - the site's uuid on `client.origin` (sync.json)
|
|
45
|
+
* @returns {Promise<{ content: object, records: object }>} each lane's account:
|
|
46
|
+
* `state` — `unchanged`, `changed` or `unknown` (with `detail`) — and what moved:
|
|
47
|
+
* content `changed` / `added` / `removed` unit paths and `outsideUnits` (the site's
|
|
48
|
+
* settings, services or queries moved, and no page did); records `changed` / `added`
|
|
49
|
+
* labels (`<schema>/<slug>`), a `removed` count and `folder` (the records' sub-folders).
|
|
50
|
+
*/
|
|
51
|
+
export async function readAppChanges({ client, siteDir, siteUuid }) {
|
|
52
|
+
const etags = etagsHold(siteDir, client.origin) ? readPullCache(siteDir) : {}
|
|
53
|
+
const bases = readBaseVersions(siteDir, client.origin)
|
|
54
|
+
const [content, folder] = await Promise.all([
|
|
55
|
+
readLane(() => client.pullSiteContent(siteUuid, { etag: etags.content })),
|
|
56
|
+
readLane(() => client.pullFolder(siteUuid, { etag: etags.folder }))
|
|
57
|
+
])
|
|
58
|
+
return {
|
|
59
|
+
content: contentChanges(content, { siteDir, origin: client.origin, siteUuid, bases }),
|
|
60
|
+
records: recordChanges(folder, { siteDir, origin: client.origin, bases })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readLane(request) {
|
|
65
|
+
let res
|
|
66
|
+
try {
|
|
67
|
+
res = await request()
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { error: err }
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 304) return { notModified: true }
|
|
72
|
+
if (!res.ok) return { status: res.status }
|
|
73
|
+
try {
|
|
74
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
75
|
+
return { docs: readPullDocuments(buf), versions: readPullVersions(buf) }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { error: err }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function laneDetail(lane) {
|
|
82
|
+
if (lane.error) return lane.error.message || String(lane.error)
|
|
83
|
+
if (lane.status === 404) return 'the backend has no such site, or you lack access to it'
|
|
84
|
+
return `the backend answered HTTP ${lane.status}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The content lane against this copy's last sync — see the header. */
|
|
88
|
+
export function contentChanges(lane, { siteDir, origin, siteUuid, bases }) {
|
|
89
|
+
const none = { changed: [], added: [], removed: [], outsideUnits: false }
|
|
90
|
+
if (lane.notModified) return { state: 'unchanged', ...none }
|
|
91
|
+
if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
|
|
92
|
+
const doc = lane.docs.find((d) => d?.info || documentSchema(d)) || lane.docs[0] || null
|
|
93
|
+
if (!doc) return { state: 'unknown', ...none, detail: 'the backend sent no site content' }
|
|
94
|
+
|
|
95
|
+
const now = lane.versions?.[siteUuid]
|
|
96
|
+
const then = bases[siteUuid]
|
|
97
|
+
if (now && then && now === then) return { state: 'unchanged', ...none }
|
|
98
|
+
|
|
99
|
+
const base = readUnitBases(siteDir, origin).remote
|
|
100
|
+
if (!Object.keys(base).length) {
|
|
101
|
+
return { state: 'unknown', ...none, detail: "this copy holds no record of the backend's content at its last sync" }
|
|
102
|
+
}
|
|
103
|
+
const hashes = computeUnitHashes(doc)
|
|
104
|
+
const changed = []
|
|
105
|
+
const added = []
|
|
106
|
+
for (const [path, hash] of Object.entries(hashes)) {
|
|
107
|
+
if (!(path in base)) added.push(path)
|
|
108
|
+
else if (base[path] !== hash) changed.push(path)
|
|
109
|
+
}
|
|
110
|
+
const removed = Object.keys(base).filter((path) => !(path in hashes))
|
|
111
|
+
const units = changed.length + added.length + removed.length
|
|
112
|
+
// The entity moved and no unit did: what moved is outside the pages — the site's
|
|
113
|
+
// settings, its services, its queries.
|
|
114
|
+
const outsideUnits = !units && Boolean(now && then)
|
|
115
|
+
return {
|
|
116
|
+
state: units || outsideUnits ? 'changed' : 'unchanged',
|
|
117
|
+
changed: changed.sort(),
|
|
118
|
+
added: added.sort(),
|
|
119
|
+
removed: removed.sort(),
|
|
120
|
+
outsideUnits
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `@acme/article` → `article`: how a record's folder under `records/` is named.
|
|
125
|
+
const shortSchema = (schema) => (typeof schema === 'string' ? schema.replace(/^@[^/]+\//, '') : null)
|
|
126
|
+
|
|
127
|
+
/** The folder lane against this copy's last sync — see the header. */
|
|
128
|
+
export function recordChanges(lane, { siteDir, origin, bases }) {
|
|
129
|
+
const none = { changed: [], added: [], removed: 0, folder: false }
|
|
130
|
+
if (lane.notModified) return { state: 'unchanged', ...none }
|
|
131
|
+
if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
|
|
132
|
+
const { folderDoc, recordDocs } = splitRecordsPull(lane.docs)
|
|
133
|
+
// The records this copy synced, by the backend's uuid (sync.json's `records`: own id → theirs).
|
|
134
|
+
const known = new Set(Object.values(readBackendState(siteDir, origin).records || {}))
|
|
135
|
+
if (!known.size && !recordDocs.length) return { state: 'unchanged', ...none }
|
|
136
|
+
|
|
137
|
+
const index = indexFolder(folderDoc)
|
|
138
|
+
const label = (doc) => {
|
|
139
|
+
const slug = index.get(doc.$uuid)?.slug ?? (typeof doc.$id === 'string' ? doc.$id.split('/').pop() : null)
|
|
140
|
+
const schema = shortSchema(documentSchema(doc))
|
|
141
|
+
return slug ? (schema ? `${schema}/${slug}` : slug) : doc.$uuid
|
|
142
|
+
}
|
|
143
|
+
const versions = lane.versions || {}
|
|
144
|
+
const changed = []
|
|
145
|
+
const added = []
|
|
146
|
+
const seen = new Set()
|
|
147
|
+
for (const doc of recordDocs) {
|
|
148
|
+
const uuid = doc?.$uuid
|
|
149
|
+
if (!uuid) continue
|
|
150
|
+
seen.add(uuid)
|
|
151
|
+
if (!known.has(uuid)) added.push(label(doc))
|
|
152
|
+
else if (bases[uuid] && versions[uuid] && bases[uuid] !== versions[uuid]) changed.push(label(doc))
|
|
153
|
+
}
|
|
154
|
+
const removed = [...known].filter((uuid) => !seen.has(uuid)).length
|
|
155
|
+
const folderUuid = folderDoc?.$uuid
|
|
156
|
+
const folder = Boolean(folderUuid && bases[folderUuid] && versions[folderUuid] && bases[folderUuid] !== versions[folderUuid])
|
|
157
|
+
const moved = changed.length || added.length || removed || folder
|
|
158
|
+
return { state: moved ? 'changed' : 'unchanged', changed: changed.sort(), added: added.sort(), removed, folder }
|
|
159
|
+
}
|
package/src/backend/client.js
CHANGED
|
@@ -948,7 +948,7 @@ export class BackendClient {
|
|
|
948
948
|
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
949
949
|
* `draft_dirty` = never-published, or the synced draft changed since the last
|
|
950
950
|
* publish ("pushed but not published"). Since 2026-10-13 it also carries `editors`:
|
|
951
|
-
* who has the site open in the app right now, and on what — `status
|
|
951
|
+
* who has the site open in the app right now, and on what — `uniweb status` shows
|
|
952
952
|
* it (`describeEditors`, commands/status.js).
|
|
953
953
|
*
|
|
954
954
|
* ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
|
|
@@ -974,14 +974,14 @@ export class BackendClient {
|
|
|
974
974
|
* The path is VERB-FIRST (`status/{uuid}`)
|
|
975
975
|
* to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
|
|
976
976
|
* not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
|
|
977
|
-
* 404 (unknown/not-yours) / 401 / any failure — `status
|
|
977
|
+
* 404 (unknown/not-yours) / 401 / any failure — `uniweb status` says what it could not tell.
|
|
978
978
|
* @param {string} uuid - the site-content uuid
|
|
979
979
|
* @returns {Promise<object|null>}
|
|
980
980
|
*/
|
|
981
981
|
/**
|
|
982
982
|
* Whether the backend holds the site `uuid` names — `live`, `gone` or `unknown`, kept
|
|
983
983
|
* apart — for a script that must decide, before any push, whether a binding still
|
|
984
|
-
* points at a site (`uniweb status --
|
|
984
|
+
* points at a site (`uniweb status --json`, its `app.site` check).
|
|
985
985
|
*
|
|
986
986
|
* ⛔ `gone` ONLY ON THE BACKEND'S OWN WORD ABOUT THIS SITE: a `404` whose problem names
|
|
987
987
|
* it — `kind: "site"`, `key: <uuid>`. A push from an unbound copy CREATES a site, so a
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* no local state, multi-machine-safe. When the site references a published
|
|
35
35
|
* registry ref or a URL there's nothing to bring along. When the backend
|
|
36
36
|
* doesn't expose the stored digest yet, the compare DEGRADES to "ask" (same
|
|
37
|
-
* posture as `status
|
|
37
|
+
* posture as `uniweb status` on a 404).
|
|
38
38
|
*
|
|
39
39
|
* "Release" here is literally `uniweb register` run in the foundation directory
|
|
40
40
|
* — same build-if-stale → schema submit → code upload → digest the standalone
|
|
@@ -164,27 +164,43 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
|
164
164
|
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
165
165
|
*/
|
|
166
166
|
export function announceServices({ siteYml, say, supports = null }) {
|
|
167
|
+
for (const note of serviceNotes({ siteYml, supports, warn: (m) => say.warn(m) })) say.warn(note)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where `site.yml::services` and the foundation disagree — the notes `announceServices`
|
|
172
|
+
* says, as lines, for a caller that reports them (`uniweb status`).
|
|
173
|
+
*
|
|
174
|
+
* @param {object} p
|
|
175
|
+
* @param {object} p.siteYml - parsed
|
|
176
|
+
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
177
|
+
* @param {(m: string) => void} [p.warn] - told what the file asks that will not be sent as written
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function serviceNotes({ siteYml, supports = null, warn = () => {} }) {
|
|
167
181
|
// An entry the push cannot read stops the package build next, which says what to write
|
|
168
182
|
// (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
|
|
169
183
|
// past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
|
|
170
|
-
if (unreadableServices(siteYml?.services).length) return
|
|
171
|
-
const asks = readServicesRequest(siteYml?.services, { warn
|
|
172
|
-
if (!Array.isArray(supports)) return
|
|
184
|
+
if (unreadableServices(siteYml?.services).length) return []
|
|
185
|
+
const asks = readServicesRequest(siteYml?.services, { warn }) || []
|
|
186
|
+
if (!Array.isArray(supports)) return []
|
|
187
|
+
const notes = []
|
|
173
188
|
const ignored = new Set(['tracking', 'records'])
|
|
174
189
|
const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
|
|
175
190
|
const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
|
|
176
191
|
if (unrendered.length) {
|
|
177
|
-
|
|
192
|
+
notes.push(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
|
|
178
193
|
}
|
|
179
194
|
const mentioned = new Set(asks.map((a) => a.name))
|
|
180
195
|
const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
|
|
181
196
|
if (unasked.length) {
|
|
182
|
-
|
|
197
|
+
notes.push(
|
|
183
198
|
`Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
|
|
184
199
|
`unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
|
|
185
200
|
`${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
|
|
186
201
|
)
|
|
187
202
|
}
|
|
203
|
+
return notes
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
/**
|
package/src/commands/doctor.js
CHANGED
|
@@ -62,11 +62,16 @@ const colors = {
|
|
|
62
62
|
blue: '\x1b[36m'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
65
|
+
// Where the report goes: the terminal — whatever `console.log` is when a line is printed —
|
|
66
|
+
// or nowhere while another command reads the findings (`doctor(args, { quiet: true })`,
|
|
67
|
+
// `uniweb status`).
|
|
68
|
+
const toConsole = (...args) => console.log(...args)
|
|
69
|
+
let print = toConsole
|
|
70
|
+
const success = (msg) => print(`${colors.green}✓${colors.reset} ${msg}`)
|
|
71
|
+
const warn = (msg) => print(`${colors.yellow}⚠${colors.reset} ${msg}`)
|
|
72
|
+
const error = (msg) => print(`${colors.red}✗${colors.reset} ${msg}`)
|
|
73
|
+
const info = (msg) => print(`${colors.blue}→${colors.reset} ${msg}`)
|
|
74
|
+
const log = (...args) => print(...args)
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* Check if a directory is a site
|
|
@@ -1196,7 +1201,24 @@ export function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, sho
|
|
|
1196
1201
|
/**
|
|
1197
1202
|
* Main doctor command
|
|
1198
1203
|
*/
|
|
1199
|
-
|
|
1204
|
+
/**
|
|
1205
|
+
* @param {string[]} [args]
|
|
1206
|
+
* @param {{ quiet?: boolean }} [opts] - `quiet`: print nothing, for a command that reads the
|
|
1207
|
+
* findings (`uniweb status`). Without `--fix` doctor writes nothing, so a quiet run is a
|
|
1208
|
+
* read.
|
|
1209
|
+
* @returns {Promise<{ issues: object[], errors: number, warnings: number, fixed: number }>}
|
|
1210
|
+
*/
|
|
1211
|
+
export async function doctor(args = [], { quiet = false } = {}) {
|
|
1212
|
+
const before = print
|
|
1213
|
+
print = quiet ? () => {} : toConsole
|
|
1214
|
+
try {
|
|
1215
|
+
return await runDoctor(args)
|
|
1216
|
+
} finally {
|
|
1217
|
+
print = before
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function runDoctor(args = []) {
|
|
1200
1222
|
log('')
|
|
1201
1223
|
log(`${colors.blue}${colors.bright}Uniweb Doctor${colors.reset}`)
|
|
1202
1224
|
log(`${colors.dim}Checking project configuration...${colors.reset}`)
|
|
@@ -1212,7 +1234,7 @@ export async function doctor(args = []) {
|
|
|
1212
1234
|
// the failure the guess was moved here to avoid. Those checks ask for the id.
|
|
1213
1235
|
const shouldFixExplicitly = (id) => fixFlag === id
|
|
1214
1236
|
const fixed = (msg) =>
|
|
1215
|
-
|
|
1237
|
+
print(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
|
|
1216
1238
|
|
|
1217
1239
|
const projectDir = resolve(process.cwd())
|
|
1218
1240
|
|
|
@@ -1225,7 +1247,14 @@ export async function doctor(args = []) {
|
|
|
1225
1247
|
log(
|
|
1226
1248
|
`${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`
|
|
1227
1249
|
)
|
|
1228
|
-
|
|
1250
|
+
// Returned, not exited, so a command reading the findings gets them; the CLI exits 1 on
|
|
1251
|
+
// an error either way.
|
|
1252
|
+
return {
|
|
1253
|
+
issues: [{ id: 'not-a-workspace', type: 'error', message: 'Not in a Uniweb workspace' }],
|
|
1254
|
+
errors: 1,
|
|
1255
|
+
warnings: 0,
|
|
1256
|
+
fixed: 0
|
|
1257
|
+
}
|
|
1229
1258
|
}
|
|
1230
1259
|
|
|
1231
1260
|
log('')
|
|
@@ -1964,6 +1993,54 @@ export async function doctor(args = []) {
|
|
|
1964
1993
|
}
|
|
1965
1994
|
}
|
|
1966
1995
|
|
|
1996
|
+
// ⛔ The keys retired on 2026-09-20 read as nothing, without a word: a deploy recorded
|
|
1997
|
+
// under `lastDeploy:` is one `uniweb status` cannot see, and `autoSave: off` is ignored,
|
|
1998
|
+
// so deploys are recorded after all. A renamed key returns empty; it does not fail.
|
|
1999
|
+
const retired = ['lastDeploy', 'autoSave'].filter((k) => deployYml[k] !== undefined)
|
|
2000
|
+
if (retired.length) {
|
|
2001
|
+
const id = 'deploy-yml-retired-key'
|
|
2002
|
+
const issue = {
|
|
2003
|
+
id,
|
|
2004
|
+
type: 'warn',
|
|
2005
|
+
site: siteName,
|
|
2006
|
+
message: `deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads`
|
|
2007
|
+
}
|
|
2008
|
+
issues.push(issue)
|
|
2009
|
+
warn(`[${id}] ${siteName}: deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads.`)
|
|
2010
|
+
const lost = []
|
|
2011
|
+
if (deployYml.lastDeploy && typeof deployYml.lastDeploy === 'object') {
|
|
2012
|
+
const names = Object.keys(deployYml.lastDeploy).filter((n) => !deployYml.deploys?.[n])
|
|
2013
|
+
if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
|
|
2014
|
+
}
|
|
2015
|
+
if (String(deployYml.autoSave) === 'off') lost.push('`autoSave: off` is ignored, so deploys are recorded')
|
|
2016
|
+
const renamed = { lastDeploy: 'deploys', autoSave: 'saveDeploys' }
|
|
2017
|
+
log(
|
|
2018
|
+
` ${colors.dim}${retired.map((k) => `\`${k}:\` is \`${renamed[k]}:\``).join(' and ')} now${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`
|
|
2019
|
+
)
|
|
2020
|
+
if (shouldFix(id)) {
|
|
2021
|
+
try {
|
|
2022
|
+
const { retireDeployYmlKeys } = await import('@uniweb/build/site')
|
|
2023
|
+
const done = await retireDeployYmlKeys(sitePath)
|
|
2024
|
+
if (done) {
|
|
2025
|
+
issue.fixed = true
|
|
2026
|
+
fixed(
|
|
2027
|
+
`${relative(workspaceDir, deployYmlPath)}: ${[
|
|
2028
|
+
done.moved.length && `moved ${done.moved.join(', ')} to \`deploys:\``,
|
|
2029
|
+
done.saveDeploys === false && 'wrote `saveDeploys: false`',
|
|
2030
|
+
'removed the old keys'
|
|
2031
|
+
]
|
|
2032
|
+
.filter(Boolean)
|
|
2033
|
+
.join(', ')}`
|
|
2034
|
+
)
|
|
2035
|
+
}
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
|
|
2038
|
+
}
|
|
2039
|
+
} else {
|
|
2040
|
+
log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to move them, keeping your comments.${colors.reset}`)
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
1967
2044
|
// github-pages-specific: if a target has `domain`, `site/public/CNAME`
|
|
1968
2045
|
// should exist with that value. Vite copies public/ into dist/, and
|
|
1969
2046
|
// GH Pages reads dist/CNAME on each deploy to keep the custom domain
|
|
@@ -2076,6 +2153,7 @@ export async function doctor(args = []) {
|
|
|
2076
2153
|
)
|
|
2077
2154
|
info(`Run: uniweb update`)
|
|
2078
2155
|
issues.push({
|
|
2156
|
+
id: 'deps-behind-cli',
|
|
2079
2157
|
type: 'warn',
|
|
2080
2158
|
message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}`
|
|
2081
2159
|
})
|
|
@@ -2093,16 +2171,17 @@ export async function doctor(args = []) {
|
|
|
2093
2171
|
if (!existsSync(agentsPath)) {
|
|
2094
2172
|
warn('AGENTS.md not found')
|
|
2095
2173
|
info(`Run: uniweb update`)
|
|
2096
|
-
issues.push({ type: 'warn', message: 'AGENTS.md not found' })
|
|
2174
|
+
issues.push({ id: 'agents-md-missing', type: 'warn', message: 'AGENTS.md not found' })
|
|
2097
2175
|
} else if (!agentsVersion) {
|
|
2098
2176
|
// No stamp — manually created or pre-stamp version
|
|
2099
2177
|
warn('AGENTS.md has no version stamp (may be outdated)')
|
|
2100
2178
|
info(`Run: uniweb update`)
|
|
2101
|
-
issues.push({ type: 'warn', message: 'AGENTS.md has no version stamp' })
|
|
2179
|
+
issues.push({ id: 'agents-md-unstamped', type: 'warn', message: 'AGENTS.md has no version stamp' })
|
|
2102
2180
|
} else if (agentsVersion !== cliVersion) {
|
|
2103
2181
|
warn(`AGENTS.md is outdated (v${agentsVersion} → v${cliVersion})`)
|
|
2104
2182
|
info(`Run: uniweb update`)
|
|
2105
2183
|
issues.push({
|
|
2184
|
+
id: 'agents-md-outdated',
|
|
2106
2185
|
type: 'warn',
|
|
2107
2186
|
message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})`
|
|
2108
2187
|
})
|