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 +1 -1
- package/partials/agents.md +16 -5
- package/partials/start.md +17 -0
- package/src/backend/app-changes.js +153 -0
- package/src/backend/client.js +3 -3
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/commands/doctor.js +41 -10
- package/src/commands/pull.js +17 -2
- package/src/commands/register.js +15 -15
- package/src/commands/status.js +712 -254
- package/src/index.js +34 -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/update-check.js +8 -6
- package/templates/site/site.yml.hbs +4 -5
package/package.json
CHANGED
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, 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
|
|
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)
|
|
@@ -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
|
|
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
|
+
}
|
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
|
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('')
|
|
@@ -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
|
})
|
package/src/commands/pull.js
CHANGED
|
@@ -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 =
|
|
785
|
+
const cacheHolds = etagsHold(siteDir, client.origin, repo)
|
|
771
786
|
let etagContent = cache.content
|
|
772
787
|
let etagFolder = cache.folder
|
|
773
788
|
|
package/src/commands/register.js
CHANGED
|
@@ -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 })
|