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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.20.4",
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.2",
51
- "@uniweb/content-reader": "~1.5.7",
52
- "@uniweb/semantic-parser": "~1.13.0"
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": {
@@ -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
- ### Three commands that replace guessing
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 push / pull / clone / status # Git-style content sync with the Uniweb backend
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 # Installed CLI version — and whether a newer one exists
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 -v # installed CLI version, and whether a newer one is available
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
+ }
@@ -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 --remote` shows
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 --remote` degrades to local.
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 --remote --json`, `remote.site_state`).
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 --remote` on a 404).
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: (m) => say.warn(m) }) || []
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
- say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
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
- say.warn(
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
  /**
@@ -62,11 +62,16 @@ const colors = {
62
62
  blue: '\x1b[36m'
63
63
  }
64
64
 
65
- const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
66
- const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
67
- const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
68
- const info = (msg) => console.log(`${colors.blue}→${colors.reset} ${msg}`)
69
- const log = console.log
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
- export async function doctor(args = []) {
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
- console.log(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
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
- process.exit(1)
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
  })