uniweb 1.20.3 → 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.
@@ -0,0 +1,126 @@
1
+ {{#if project}}
2
+ # Working on a Uniweb project
3
+
4
+ You're in the Uniweb project at `{{root}}`.
5
+
6
+ Its pages are markdown files, and how they look and behave comes from its foundation:
7
+ React components that the pages pick by name. The `uniweb` CLI builds and runs the
8
+ project, and brings it into the Uniweb app, where the person you're working with can keep
9
+ editing it visually.
10
+
11
+ `uniweb start` only prints this guide; `npx uniweb dev` runs the site. To start a new
12
+ project instead, run `npx uniweb@latest start` in a folder outside this one.
13
+ {{else}}
14
+ # Build a website with Uniweb
15
+
16
+ You're building a website for the person you're working with. In Uniweb, the pages are
17
+ markdown files, and how they look and behave comes from a foundation: React components
18
+ that the pages pick by name. One project holds both. The `uniweb` CLI builds and runs it,
19
+ and brings it into the Uniweb app, where the person can keep editing it visually.
20
+ {{/if}}
21
+
22
+ ## The person doesn't use a terminal
23
+
24
+ Run every command yourself, and never ask them to run one. They do two things, in their
25
+ browser: sign in, and pay when the site goes live. When a command prints an address to
26
+ finish something in a browser, open it for them and say what it's for.
27
+
28
+ Uniweb needs Node.js 22.12 or later. If `node --version` shows an older one, install a
29
+ current one yourself.
30
+ {{#unless project}}
31
+
32
+ ## Create the project
33
+
34
+ ```bash
35
+ npx uniweb@latest create <name> --template starter
36
+ cd <name>
37
+ ```
38
+
39
+ `<name>` is lowercase letters, digits and hyphens, and becomes the project's folder.
40
+ `create` asks no questions when it runs without a terminal. `starter` has one generic
41
+ section type and two short pages: a working example of each convention, to replace as
42
+ you build. `--template none` is the same project without them.
43
+ {{#if otherProject}}
44
+
45
+ Create it outside `{{otherProject}}`: that folder holds another project, and `create`
46
+ does not make one inside it.
47
+ {{/if}}
48
+ {{/unless}}
49
+
50
+ ## Read the project's guide
51
+
52
+ {{#if agents}}
53
+ AGENTS.md, at the project's root, is the manual for this project and its version of
54
+ Uniweb. Read its Part 0 before writing code: it says which parts your task needs.
55
+ {{else}}
56
+ This project has no AGENTS.md, the manual for a Uniweb project. Write one with
57
+ `npx uniweb update --agents-only`, then read its Part 0 before writing code: it says
58
+ which parts your task needs.
59
+ {{/if}}
60
+
61
+ Uniweb's mistakes are quiet: an unknown section type or param builds fine and renders
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}}
76
+
77
+ ## Install and run
78
+
79
+ ```bash
80
+ {{install}}
81
+ npx uniweb dev
82
+ ```
83
+
84
+ Run `dev` in the background. Once the server listens, it prints
85
+ `Serving <site> at <url>`: open that address to show the person.
86
+ `npx uniweb dev --stop` stops it.
87
+
88
+ ## Sign them in to Uniweb
89
+
90
+ When they want the site in the Uniweb app, to edit it there or to take it live:
91
+
92
+ ```bash
93
+ npx uniweb login --browser --personal
94
+ ```
95
+
96
+ This opens their browser. If they're signed in to Uniweb there, it finishes on its own;
97
+ otherwise they sign in, or sign up if they're new, and it waits up to five minutes. Never
98
+ ask for a password or a token.
99
+
100
+ `--personal` puts their sites in their own workspace. If they ask for one of their
101
+ organizations instead, use `--org @<its handle>`.
102
+
103
+ ## Bring the site into the app, and take it live
104
+
105
+ ```bash
106
+ npx uniweb push --commit -m "<what you built>" # to see or edit it in the app
107
+ npx uniweb publish --commit -m "<what you built>" # to take it live
108
+ ```
109
+
110
+ `push` sends the site and its foundation to their workspace, where they'll find it in
111
+ the app. `publish` does the same and then takes the site live, so it needs no push
112
+ first. `--commit` commits the site's content first: in a git repository, both send only
113
+ what is committed.
114
+
115
+ If the site needs paying for, `publish` prints an address instead of going live. Open
116
+ it for the person, tell them it's where they pay, and run `publish` again once they're
117
+ done.
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
+
123
+ ## More
124
+
125
+ - Documentation index: https://uniweb.io/llms.txt
126
+ - Every command's options: `npx uniweb <command> --help`
@@ -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
@@ -244,6 +244,28 @@ async function openDoor({ url, say, open }) {
244
244
  return { opened }
245
245
  }
246
246
 
247
+ /**
248
+ * Where a charge that was not asked is confirmed — at a terminal with `--yes`, or with no one at
249
+ * the terminal at all.
250
+ *
251
+ * ⛔ **UNATTENDED, NEVER "AT A TERMINAL".** The run with no one at the terminal is often an AI agent
252
+ * working for someone who does not use one, and it passes this line on: the person confirms in
253
+ * their browser, at the door the refusal printed, or in the app when there is none. *(Until
254
+ * 2026-10-26 every case said "run `uniweb publish` at a terminal".)* Either way a person confirms
255
+ * it, which is why the line says that `--yes` cannot.
256
+ *
257
+ * @param {{ interactive: boolean, door: boolean }} o - someone is at the terminal; a door was printed
258
+ * @returns {string}
259
+ */
260
+ function confirmChargeLine({ interactive, door }) {
261
+ if (interactive) {
262
+ return 'This change charges the site\'s card, so only you can confirm it: run `uniweb publish` without `--yes`.'
263
+ }
264
+ return door
265
+ ? 'This change charges the site\'s card, so a person confirms it, in a browser at the address above. `--yes` never confirms a charge.'
266
+ : 'This change charges the site\'s card, so a person confirms it, in the app. `--yes` never confirms a charge.'
267
+ }
268
+
247
269
  const CONFIRM = 'Confirm and publish'
248
270
  const CONTINUE = 'Continue and publish'
249
271
  const OPEN = 'Open it in the app'
@@ -275,7 +297,8 @@ const MAX_UNATTENDED = 3
275
297
  * "allow it for reductions only, keep charges interactive".]*
276
298
  *
277
299
  * With no one at the terminal and nothing to answer it, there is no prompt: the refusal is reported as
278
- * before, its door printed, and the publish fails. A refusal with no `confirm` behaves as it always has.
300
+ * before, its door printed, and the publish fails — and a charge says where a person confirms it, in a
301
+ * browser (`confirmChargeLine`). A refusal with no `confirm` behaves as it always has.
279
302
  *
280
303
  * @param {object} o
281
304
  * @param {ReturnType<typeof readPaymentRefusal>} o.verdict - the first refusal
@@ -312,7 +335,7 @@ export async function settleRefusal({ verdict, args = [], say, republish, intera
312
335
  } else if (!offer || !interactive || (yes && !reduction)) {
313
336
  await reportPaymentRefusal({ verdict: current, args, say, open, interactive: interactive && !yes })
314
337
  if (offer && !reduction) {
315
- say.dim('This change charges the site\'s card, so only you can confirm it: run `uniweb publish` at a terminal, without `--yes`.')
338
+ say.dim(confirmChargeLine({ interactive, door: current.kind === 'settle' }))
316
339
  } else if (reduction) {
317
340
  say.dim('This change only lowers what the site pays: `uniweb publish --yes` continues it without a prompt.')
318
341
  }
@@ -421,6 +421,8 @@ export async function clone(args = [], deps = {}) {
421
421
  {
422
422
  projectName: workspaceName,
423
423
  workspaceGlobs: ['site'],
424
+ packages: [{ path: 'site', site: true }],
425
+ cloned: true,
424
426
  scripts: { dev: 'uniweb dev', build: 'uniweb build' }
425
427
  },
426
428
  { onProgress }
@@ -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 })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `uniweb start` — the guide an AI agent reads first.
3
+ *
4
+ * ⭐ FOR AN AGENT WORKING FOR SOMEONE WHO DOES NOT USE A TERMINAL. The person asks their
5
+ * agent for a website; the agent runs `npx uniweb@latest start` and follows what it
6
+ * prints: create a project, read its AGENTS.md, run it, sign the person in, and bring the
7
+ * site into the Uniweb app. The person's part is their browser — signing in, and paying
8
+ * when the site goes live — so nothing here asks them to run a command.
9
+ *
10
+ * ⛔ READ-ONLY, AND SAFE ANYWHERE. It asks nothing, writes nothing and makes no request,
11
+ * so an agent can run it in any folder, as often as it likes. That is also why
12
+ * `index.js` answers it before the update check, which can write a cache and fetch.
13
+ *
14
+ * ⭐ THE TEXT SHIPS WITH THIS CLI (`partials/start.md`), so it describes the version that
15
+ * prints it. Inside a Uniweb project (`findUniwebWorkspace`) it starts from that project
16
+ * instead of creating one, and names that project's own package manager.
17
+ *
18
+ * It is answered by the CLI that was invoked, never delegated to a project's own copy
19
+ * (`STANDALONE_COMMANDS`): an older project CLI does not have it.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+ import Handlebars from 'handlebars'
26
+ import { findUniwebWorkspace, findWorkspaceRoot } from '../utils/workspace.js'
27
+ import { detectWorkspacePm, installCmd } from '../utils/pm.js'
28
+
29
+ const PARTIAL = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'partials', 'start.md')
30
+
31
+ /**
32
+ * The guide, as Markdown, for a command run in `cwd`.
33
+ *
34
+ * Outside a project, `otherProject` names a workspace of another kind that holds `cwd`:
35
+ * `create` refuses to make a project inside one, so the guide says to create it elsewhere.
36
+ *
37
+ * @param {{ cwd?: string }} [o]
38
+ * @returns {string}
39
+ */
40
+ export function startGuide({ cwd = process.cwd() } = {}) {
41
+ const root = findUniwebWorkspace(cwd)
42
+ const template = Handlebars.compile(readFileSync(PARTIAL, 'utf8'), { noEscape: true })
43
+ return template({
44
+ project: !!root,
45
+ root,
46
+ // A project `create` makes has one; one already here may not.
47
+ agents: root ? existsSync(join(root, 'AGENTS.md')) : true,
48
+ otherProject: root ? null : findWorkspaceRoot(cwd),
49
+ install: installCmd(root ? detectWorkspacePm(root) : 'npm')
50
+ })
51
+ }
52
+
53
+ /**
54
+ * @param {string[]} [args] - none are read
55
+ * @returns {Promise<{ exitCode: number }>}
56
+ */
57
+ export async function start(args = []) {
58
+ console.log(startGuide())
59
+ return { exitCode: 0 }
60
+ }