uniweb 1.20.4 → 1.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.20.4",
3
+ "version": "1.21.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, before you push, and after a command refuses: it changes nothing, 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)
@@ -2896,6 +2902,8 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2896
2902
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2897
2903
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2898
2904
 
2905
+ **Working on a site others edit — run `uniweb status` when you begin, before you push, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file; what you hold that it lacks; whether the site is live; 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.
2906
+
2899
2907
  **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
2908
 
2901
2909
  **`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 +2950,15 @@ Either side can publish. Nothing about this changes how you build: the same foun
2942
2950
  ### Staying current
2943
2951
 
2944
2952
  ```bash
2945
- uniweb -v # installed CLI version, and whether a newer one is available
2953
+ uniweb status # among its checks: whether a newer Uniweb is out than this project runs
2954
+ uniweb -v # CLI version — only a global install also says whether a newer one exists
2946
2955
  uniweb doctor # report drift in this project, changing nothing
2947
2956
  npx uniweb@latest update --dry-run # preview exactly what update would change
2948
2957
  npx uniweb@latest update # apply: align @uniweb/* deps AND refresh AGENTS.md
2949
2958
  ```
2950
2959
 
2960
+ 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.
2961
+
2951
2962
  **`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
2963
 
2953
2964
  **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,153 @@
1
+ /**
2
+ * What changed on the backend since this copy last synced — READ, never taken.
3
+ *
4
+ * `uniweb pull` takes the backend's content; `uniweb status` asks what there is to take.
5
+ * The two must agree on what "changed" means, so this reads the backend as a pull does —
6
+ * the same two lanes, the same conditional GET (`etagsHold`) — and compares the answer
7
+ * with what this copy recorded at its last sync, each in the backend's own representation:
8
+ *
9
+ * - the site-content entity's version against `baseVersions`: nothing moved, said
10
+ * precisely;
11
+ * - each unit's hash against `unitBases.remote`, which a pull and a push both bank from
12
+ * the backend's own copy: which files moved. A unit's hash is only ever compared with a
13
+ * base in its own representation (`@uniweb/build`'s `site-diff.js`);
14
+ * - each record's version against `baseVersions`: which records moved.
15
+ *
16
+ * Whoever made the change — an author in the app, or a push from another copy — it is the
17
+ * backend's content this copy does not hold yet.
18
+ *
19
+ * ⛔ IT TAKES NOTHING. An ETag, a version or a hash banked here would say this copy holds
20
+ * content it never took, and the next pull would hear 304 and never catch up — the failure
21
+ * `pull.js` records beside `PULL_CACHE_VERSION`. Nothing here writes a file.
22
+ */
23
+
24
+ import { computeUnitHashes, documentSchema, indexFolder, readBackendState } from '@uniweb/build/uwx'
25
+ import {
26
+ etagsHold,
27
+ readPullCache,
28
+ readPullDocuments,
29
+ readPullVersions,
30
+ splitRecordsPull
31
+ } from '../commands/pull.js'
32
+ import { readBaseVersions, readUnitBases } from './site-sync.js'
33
+
34
+ /**
35
+ * @param {object} p
36
+ * @param {import('./client.js').BackendClient} p.client - signed in; never prompts here
37
+ * @param {string} p.siteDir
38
+ * @param {string} p.siteUuid - the site's uuid on `client.origin` (sync.json)
39
+ * @returns {Promise<{ content: object, records: object }>} each lane's account:
40
+ * `state` — `unchanged`, `changed` or `unknown` (with `detail`) — and what moved:
41
+ * content `changed` / `added` / `removed` unit paths and `outsideUnits` (the site's
42
+ * settings, services or queries moved, and no page did); records `changed` / `added`
43
+ * labels (`<schema>/<slug>`), a `removed` count and `folder` (the records' sub-folders).
44
+ */
45
+ export async function readAppChanges({ client, siteDir, siteUuid }) {
46
+ const etags = etagsHold(siteDir, client.origin) ? readPullCache(siteDir) : {}
47
+ const bases = readBaseVersions(siteDir, client.origin)
48
+ const [content, folder] = await Promise.all([
49
+ readLane(() => client.pullSiteContent(siteUuid, { etag: etags.content })),
50
+ readLane(() => client.pullFolder(siteUuid, { etag: etags.folder }))
51
+ ])
52
+ return {
53
+ content: contentChanges(content, { siteDir, origin: client.origin, siteUuid, bases }),
54
+ records: recordChanges(folder, { siteDir, origin: client.origin, bases })
55
+ }
56
+ }
57
+
58
+ async function readLane(request) {
59
+ let res
60
+ try {
61
+ res = await request()
62
+ } catch (err) {
63
+ return { error: err }
64
+ }
65
+ if (res.status === 304) return { notModified: true }
66
+ if (!res.ok) return { status: res.status }
67
+ try {
68
+ const buf = Buffer.from(await res.arrayBuffer())
69
+ return { docs: readPullDocuments(buf), versions: readPullVersions(buf) }
70
+ } catch (err) {
71
+ return { error: err }
72
+ }
73
+ }
74
+
75
+ function laneDetail(lane) {
76
+ if (lane.error) return lane.error.message || String(lane.error)
77
+ if (lane.status === 404) return 'the backend has no such site, or you lack access to it'
78
+ return `the backend answered HTTP ${lane.status}`
79
+ }
80
+
81
+ /** The content lane against this copy's last sync — see the header. */
82
+ export function contentChanges(lane, { siteDir, origin, siteUuid, bases }) {
83
+ const none = { changed: [], added: [], removed: [], outsideUnits: false }
84
+ if (lane.notModified) return { state: 'unchanged', ...none }
85
+ if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
86
+ const doc = lane.docs.find((d) => d?.info || documentSchema(d)) || lane.docs[0] || null
87
+ if (!doc) return { state: 'unknown', ...none, detail: 'the backend sent no site content' }
88
+
89
+ const now = lane.versions?.[siteUuid]
90
+ const then = bases[siteUuid]
91
+ if (now && then && now === then) return { state: 'unchanged', ...none }
92
+
93
+ const base = readUnitBases(siteDir, origin).remote
94
+ if (!Object.keys(base).length) {
95
+ return { state: 'unknown', ...none, detail: "this copy holds no record of the backend's content at its last sync" }
96
+ }
97
+ const hashes = computeUnitHashes(doc)
98
+ const changed = []
99
+ const added = []
100
+ for (const [path, hash] of Object.entries(hashes)) {
101
+ if (!(path in base)) added.push(path)
102
+ else if (base[path] !== hash) changed.push(path)
103
+ }
104
+ const removed = Object.keys(base).filter((path) => !(path in hashes))
105
+ const units = changed.length + added.length + removed.length
106
+ // The entity moved and no unit did: what moved is outside the pages — the site's
107
+ // settings, its services, its queries.
108
+ const outsideUnits = !units && Boolean(now && then)
109
+ return {
110
+ state: units || outsideUnits ? 'changed' : 'unchanged',
111
+ changed: changed.sort(),
112
+ added: added.sort(),
113
+ removed: removed.sort(),
114
+ outsideUnits
115
+ }
116
+ }
117
+
118
+ // `@acme/article` → `article`: how a record's folder under `records/` is named.
119
+ const shortSchema = (schema) => (typeof schema === 'string' ? schema.replace(/^@[^/]+\//, '') : null)
120
+
121
+ /** The folder lane against this copy's last sync — see the header. */
122
+ export function recordChanges(lane, { siteDir, origin, bases }) {
123
+ const none = { changed: [], added: [], removed: 0, folder: false }
124
+ if (lane.notModified) return { state: 'unchanged', ...none }
125
+ if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
126
+ const { folderDoc, recordDocs } = splitRecordsPull(lane.docs)
127
+ // The records this copy synced, by the backend's uuid (sync.json's `records`: own id → theirs).
128
+ const known = new Set(Object.values(readBackendState(siteDir, origin).records || {}))
129
+ if (!known.size && !recordDocs.length) return { state: 'unchanged', ...none }
130
+
131
+ const index = indexFolder(folderDoc)
132
+ const label = (doc) => {
133
+ const slug = index.get(doc.$uuid)?.slug ?? (typeof doc.$id === 'string' ? doc.$id.split('/').pop() : null)
134
+ const schema = shortSchema(documentSchema(doc))
135
+ return slug ? (schema ? `${schema}/${slug}` : slug) : doc.$uuid
136
+ }
137
+ const versions = lane.versions || {}
138
+ const changed = []
139
+ const added = []
140
+ const seen = new Set()
141
+ for (const doc of recordDocs) {
142
+ const uuid = doc?.$uuid
143
+ if (!uuid) continue
144
+ seen.add(uuid)
145
+ if (!known.has(uuid)) added.push(label(doc))
146
+ else if (bases[uuid] && versions[uuid] && bases[uuid] !== versions[uuid]) changed.push(label(doc))
147
+ }
148
+ const removed = [...known].filter((uuid) => !seen.has(uuid)).length
149
+ const folderUuid = folderDoc?.$uuid
150
+ const folder = Boolean(folderUuid && bases[folderUuid] && versions[folderUuid] && bases[folderUuid] !== versions[folderUuid])
151
+ const moved = changed.length || added.length || removed || folder
152
+ return { state: moved ? 'changed' : 'unchanged', changed: changed.sort(), added: added.sort(), removed, folder }
153
+ }
@@ -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
@@ -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('')
@@ -2076,6 +2105,7 @@ export async function doctor(args = []) {
2076
2105
  )
2077
2106
  info(`Run: uniweb update`)
2078
2107
  issues.push({
2108
+ id: 'deps-behind-cli',
2079
2109
  type: 'warn',
2080
2110
  message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}`
2081
2111
  })
@@ -2093,16 +2123,17 @@ export async function doctor(args = []) {
2093
2123
  if (!existsSync(agentsPath)) {
2094
2124
  warn('AGENTS.md not found')
2095
2125
  info(`Run: uniweb update`)
2096
- issues.push({ type: 'warn', message: 'AGENTS.md not found' })
2126
+ issues.push({ id: 'agents-md-missing', type: 'warn', message: 'AGENTS.md not found' })
2097
2127
  } else if (!agentsVersion) {
2098
2128
  // No stamp — manually created or pre-stamp version
2099
2129
  warn('AGENTS.md has no version stamp (may be outdated)')
2100
2130
  info(`Run: uniweb update`)
2101
- issues.push({ type: 'warn', message: 'AGENTS.md has no version stamp' })
2131
+ issues.push({ id: 'agents-md-unstamped', type: 'warn', message: 'AGENTS.md has no version stamp' })
2102
2132
  } else if (agentsVersion !== cliVersion) {
2103
2133
  warn(`AGENTS.md is outdated (v${agentsVersion} → v${cliVersion})`)
2104
2134
  info(`Run: uniweb update`)
2105
2135
  issues.push({
2136
+ id: 'agents-md-outdated',
2106
2137
  type: 'warn',
2107
2138
  message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})`
2108
2139
  })
@@ -238,7 +238,7 @@ function pullCachePath(siteDir) {
238
238
  // Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
239
239
  // the sync point it was banked with is in the history — see `cacheHolds` below.)
240
240
  const PULL_CACHE_VERSION = 2
241
- function readPullCache(siteDir) {
241
+ export function readPullCache(siteDir) {
242
242
  try {
243
243
  const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
244
244
  return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
@@ -246,6 +246,21 @@ function readPullCache(siteDir) {
246
246
  return {}
247
247
  }
248
248
  }
249
+
250
+ /**
251
+ * Whether the ETags this copy banked still describe its files — the condition a
252
+ * conditional GET is sent on. Outside git, always. In a repository, only while HEAD's
253
+ * history holds the sync point they were banked with: a merge abandoned, a branch reset,
254
+ * and the backend did not move but the files did.
255
+ *
256
+ * One rule for both readers of the lanes — this pull and `uniweb status`, which asks the
257
+ * same question without taking the answer.
258
+ */
259
+ export function etagsHold(siteDir, origin, repo = repoOf(siteDir)) {
260
+ if (!repo) return true
261
+ const held = readSyncPoint(siteDir, origin)
262
+ return Boolean(held && isAncestor(repo, held))
263
+ }
249
264
  function writePullCache(siteDir, { content, folder }) {
250
265
  const p = pullCachePath(siteDir)
251
266
  mkdirSync(dirname(p), { recursive: true })
@@ -767,7 +782,7 @@ export async function pull(args = [], deps = {}) {
767
782
  // only while the sync point it was banked with is.
768
783
  const cache = readPullCache(siteDir)
769
784
  const held = repo ? readSyncPoint(siteDir, client.origin) : null
770
- const cacheHolds = !repo || Boolean(held && isAncestor(repo, held))
785
+ const cacheHolds = etagsHold(siteDir, client.origin, repo)
771
786
  let etagContent = cache.content
772
787
  let etagFolder = cache.folder
773
788
 
@@ -385,21 +385,6 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
385
385
  return { scope, source }
386
386
  }
387
387
 
388
- /**
389
- * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
390
- * when none was chosen.
391
- *
392
- * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
393
- * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
394
- * working in `@acme` registers `@acme/…`, which any of its members can release.
395
- * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
396
- * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
397
- * renaming it into a new foundation.
398
- *
399
- * When the workspace names no scope — none is chosen, or it is a unit without a handle —
400
- * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
401
- * you belong to none, else a pick at a terminal, your personal scope without one.
402
- */
403
388
  /**
404
389
  * Refuse a reserved scope wherever it came from, before anything is written or sent —
405
390
  * `@uniweb` names the framework's own packages and a system account on every Uniweb
@@ -417,6 +402,21 @@ function refusedReservedScope(scope, lead) {
417
402
  return true
418
403
  }
419
404
 
405
+ /**
406
+ * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
407
+ * when none was chosen.
408
+ *
409
+ * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
410
+ * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
411
+ * working in `@acme` registers `@acme/…`, which any of its members can release.
412
+ * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
413
+ * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
414
+ * renaming it into a new foundation.
415
+ *
416
+ * When the workspace names no scope — none is chosen, or it is a unit without a handle —
417
+ * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
418
+ * you belong to none, else a pick at a terminal, your personal scope without one.
419
+ */
420
420
  async function deriveScopeFromLogin(client, args) {
421
421
  const token = await client.token()
422
422
  const ws = await resolveWorkspace({ client, args })