uniweb 0.28.1 → 0.29.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": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/core": "^0.11.3",
45
+ "@uniweb/kit": "^0.13.4",
44
46
  "@uniweb/runtime": "^0.12.9",
45
- "@uniweb/kit": "^0.13.3",
46
- "@uniweb/core": "^0.11.2",
47
47
  "@uniweb/semantic-parser": "^1.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@uniweb/build": "^0.25.3",
50
51
  "@uniweb/semantic-parser": "^1.3.0",
51
- "@uniweb/content-reader": "^1.2.4",
52
- "@uniweb/build": "^0.25.2"
52
+ "@uniweb/content-reader": "^1.2.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2128,8 +2128,12 @@ happened into a journey.
2128
2128
  > or tabs, and **nothing is written to the visitor's device** — no cookie, no
2129
2129
  > local storage. The `visit` key lives in memory and dies with the document, so
2130
2130
  > it identifies one page load rather than a person. A `page_view` carries the
2131
- > path, and captured once when the page first loads, then replayed on each view
2132
- > the external referrer and any `utm_*` the visitor arrived with. Nothing else.
2131
+ > path; two booleans about the page load `first_of_load` on the view that
2132
+ > opened the document, and `continues` when the load came from one of your own
2133
+ > pages; and — captured once when the page first loads, then replayed on each
2134
+ > view — the external referrer and any `utm_*` the visitor arrived with. Nothing
2135
+ > else. **Both booleans describe the load, never the visitor**, and neither links
2136
+ > two loads to each other.
2133
2137
 
2134
2138
  ### ⛔ Use kit. Never touch the `uniweb` global
2135
2139
 
@@ -48,16 +48,35 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
48
48
  *
49
49
  * 1. `flag` — the raw --backend / --registry value (this command)
50
50
  * 2. UNIWEB_REGISTER_URL env — session-wide override (CI / local dev)
51
- * 3. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
52
- * 4–6. the logged-in session origin > ~/.uniweb/config.json > the default
51
+ * 3. `siteScope` — the project's `site.yml::$backend` (site verbs)
52
+ * 4. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
53
+ * 5–7. the logged-in session origin > ~/.uniweb/config.json > the default
53
54
  * (uniweb.app) — all via getRegistryApiBaseUrl()
54
55
  *
56
+ * ⭐ **`siteScope` outranks the SESSION, and that is the whole reason it is a tier.** The
57
+ * session is a machine-wide default; `$backend` is this project's statement about where
58
+ * its stored identity is meaningful. Without the tier, a teammate who clones a project
59
+ * bound to a non-default backend resolves to whatever they last logged into, and is then
60
+ * REFUSED by `assertSiteBackendScope` — nagged instead of routed, on a question the
61
+ * project already answered.
62
+ *
63
+ * ⭐ **And it outranks `siteBackend`, which looks like the same fact and is not.**
64
+ * `deploy.yml`'s target `backend` records *where this ships*; `$backend` records *whose
65
+ * namespace the uuid is in*. They agree in the normal case. When they disagree the
66
+ * identity wins, because resolving to the other one only reaches the guard and stops.
67
+ *
68
+ * ⚠️ The explicit overrides stay ON TOP deliberately. `--backend` and the env var are how
69
+ * you deliberately aim elsewhere — at a staging mirror, say — and a project file must not
70
+ * be able to veto a flag the user just typed. A wrong aim is then caught by the guard
71
+ * rather than silently redirected.
72
+ *
55
73
  * @param {string} [flag] - the raw value of --backend / --registry, if supplied
56
74
  * @param {object} [opts]
75
+ * @param {string} [opts.siteScope] - the project's `site.yml::$backend`
57
76
  * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
58
77
  * @returns {string} a bare origin with no trailing slash
59
78
  */
60
- export function resolveBackendOrigin(flag, { siteBackend } = {}) {
79
+ export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
61
80
  const norm = (v) => {
62
81
  try {
63
82
  return new URL(v).origin
@@ -74,6 +93,10 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
74
93
  const o = norm(env)
75
94
  if (o) return o
76
95
  }
96
+ if (siteScope) {
97
+ const o = norm(siteScope)
98
+ if (o) return o
99
+ }
77
100
  if (siteBackend) {
78
101
  const o = norm(siteBackend)
79
102
  if (o) return o
@@ -116,6 +139,7 @@ export class BackendClient {
116
139
  * @param {object} [opts]
117
140
  * @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
118
141
  * @param {string} [opts.originFlag] - raw --backend/--registry value to resolve
142
+ * @param {string} [opts.siteScope] - the project's `site.yml::$backend` (site verbs)
119
143
  * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
120
144
  * @param {string} [opts.token] - explicit bearer (wins over env + stored session)
121
145
  * @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
@@ -127,6 +151,7 @@ export class BackendClient {
127
151
  constructor({
128
152
  origin,
129
153
  originFlag,
154
+ siteScope,
130
155
  siteBackend,
131
156
  token,
132
157
  getToken,
@@ -135,7 +160,7 @@ export class BackendClient {
135
160
  fetchImpl
136
161
  } = {}) {
137
162
  this.origin = (
138
- origin || resolveBackendOrigin(originFlag, { siteBackend })
163
+ origin || resolveBackendOrigin(originFlag, { siteScope, siteBackend })
139
164
  ).replace(/\/+$/, '')
140
165
  this._token = token || process.env.UNIWEB_TOKEN || null
141
166
  this._getToken = getToken || null
@@ -15,6 +15,7 @@ import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
15
15
  import { join, dirname } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
+ import { recordSiteBackend } from '../utils/site-identity.js'
18
19
  import {
19
20
  backfillEntityUuids,
20
21
  writeSiteEntityUuid,
@@ -796,6 +797,16 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
796
797
  echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
797
798
  const org = recordSiteOrg(siteDir, owner)
798
799
 
800
+ // The SYNC SCOPE, recorded here for the same reason `$org` is: this function is the
801
+ // one place BOTH create paths meet (`ensureSiteExists` for a site with local media,
802
+ // the content-lane create for one without). Recording it at either call site instead
803
+ // would make `$backend` present or absent depending on whether the site happens to
804
+ // have images — the exact drift the comment at the second call site warns about.
805
+ //
806
+ // A no-op on the default backend, so the common case writes nothing.
807
+ const scope = await recordSiteBackend(siteDir, client.origin)
808
+ if (scope) note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
809
+
799
810
  note?.(
800
811
  org
801
812
  ? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
@@ -1088,11 +1099,29 @@ export async function pushSyncPackages({
1088
1099
  // verb that could have removed it — a site is deleted in the Uniweb app,
1089
1100
  // and that is what severs the sync — so the raw 404 leaves the user with
1090
1101
  // no idea that the fix is local and one line.
1102
+ //
1103
+ // ⛔ ORDER THESE TWO CAUSES, and put the RECOVERABLE one first. Until 2026-08-24
1104
+ // this branch named only the deletion and went straight to "clear `$uuid`" — and
1105
+ // that advice, followed for the OTHER cause, destroys a live binding: the site is
1106
+ // fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
1107
+ // it. `assertSiteBackendScope` now catches most of that before any request goes
1108
+ // out, so reaching here usually does mean a deletion; "usually" is not "always"
1109
+ // (a project predating `$backend` records no scope to check), which is why the
1110
+ // cheap cause is still named before the destructive fix.
1111
+ note(
1112
+ `The backend at ${client.origin} has no site with uuid ${boundUuid}.`
1113
+ )
1114
+ note(
1115
+ 'Two causes. Check the cheap one first: is this the backend the site lives on?'
1116
+ )
1117
+ note(
1118
+ ` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
1119
+ )
1091
1120
  note(
1092
- `The backend has no site with uuid ${boundUuid} the usual cause is that it was deleted in the Uniweb app.`
1121
+ ` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
1093
1122
  )
1094
1123
  note(
1095
- 'Deleting this folder removes only your local copy; clearing `$uuid` from site.yml re-publishes it as a new site.'
1124
+ 'Deleting this folder removes only your local copy, either way.'
1096
1125
  )
1097
1126
  } else if (res.status === 409) {
1098
1127
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
@@ -59,6 +59,7 @@ import { extractFoundationRef } from '../utils/site-content-refs.js'
59
59
  import { readUwxDocuments } from '../utils/uwx-read.js'
60
60
  import { recordWritten } from '../utils/pull-written.js'
61
61
  import { checkFlags } from '../utils/flag-guard.js'
62
+ import { recordSiteBackend } from '../utils/site-identity.js'
62
63
 
63
64
  const colors = {
64
65
  reset: '\x1b[0m',
@@ -335,9 +336,17 @@ export async function clone(args = [], deps = {}) {
335
336
  // same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
336
337
  // separate folder uuid to seed.
337
338
  seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
339
+ // …and the SYNC SCOPE that makes it meaningful. `clone` is the sharpest case for it:
340
+ // the uuid comes from whoever we read, and until now nothing on disk recorded that —
341
+ // so a teammate who cloned this project and ran `uniweb pull` while logged in
342
+ // elsewhere sent this backend's uuid to a different one. A no-op on the default
343
+ // backend. (The two CREATE paths record it via `recordAndDescribeOwner`; clone seeds
344
+ // an existing site and never reaches them, which is why it is written here too.)
345
+ const scope = await recordSiteBackend(siteDir, client.origin)
338
346
  success(
339
347
  `Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
340
348
  )
349
+ if (scope) note(`Bound to ${scope} (recorded $backend in site.yml).`)
341
350
 
342
351
  // 5. Install, then delegate the projection to the project-local `uniweb pull`.
343
352
  const pm = detectWorkspacePm(projectDir) || 'pnpm'
@@ -65,6 +65,10 @@ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
65
65
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
66
66
  import { readFlagValue, readOrgFlag } from '../utils/args.js'
67
67
  import { checkFlags } from '../utils/flag-guard.js'
68
+ import {
69
+ assertSiteBackendScope,
70
+ readSiteIdentity
71
+ } from '../utils/site-identity.js'
68
72
  import { isNonInteractive, confirm } from '../utils/interactive.js'
69
73
  import { headProvenance } from '../utils/git.js'
70
74
  import {
@@ -180,17 +184,33 @@ export async function publish(args = []) {
180
184
  const siteYml = readSiteYml(join(siteDir, 'site.yml'))
181
185
  // The site's deploy.yml-bound backend (where it was published) feeds the
182
186
  // resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
187
+ // The project's own statement of where its identity lives. Feeds the origin ladder
188
+ // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
189
+ // targets the backend it is bound to instead of whatever they last logged into.
190
+ // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
191
+ const siteScope = readSiteIdentity(siteDir).backend
183
192
  const siteBackend = await resolveSiteBackend(siteDir)
184
193
 
185
194
  const client = new BackendClient({
186
195
  originFlag:
187
196
  readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
197
+ siteScope,
188
198
  siteBackend,
189
199
  token: readFlagValue(args, '--token') || undefined,
190
200
  args,
191
201
  command: 'Publishing'
192
202
  })
193
203
 
204
+ // ⛔ SCOPE CHECK — before the foundation bring-along, the sync, or the go-live. A
205
+ // publish is the longest of these flows and the most expensive to unwind, so it is
206
+ // the one that most benefits from failing at the first step rather than at the fifth.
207
+ const scope = assertSiteBackendScope(siteDir, client.origin)
208
+ if (!scope.ok) {
209
+ say.err(scope.message)
210
+ scope.hint.forEach(say.dim)
211
+ return { exitCode: 1 }
212
+ }
213
+
194
214
  // WHO will own this site, if this publish is the one that creates it. Resolved
195
215
  // up front: `ensureSiteExists` below is the create, and it must not be reached
196
216
  // with the question still open. An already-created site resolves to null without
@@ -102,6 +102,10 @@ import {
102
102
  resolveSiteBackend
103
103
  } from './deploy.js'
104
104
  import { checkFlags } from '../utils/flag-guard.js'
105
+ import {
106
+ assertSiteBackendScope,
107
+ readSiteIdentity
108
+ } from '../utils/site-identity.js'
105
109
 
106
110
  const FOLDER_MODEL = '@uniweb/folder'
107
111
 
@@ -549,9 +553,15 @@ export async function pull(args = [], deps = {}) {
549
553
  const blocked = await checkWorkingTree(siteDir, args)
550
554
  if (blocked) return blocked
551
555
  }
556
+ // The project's own statement of where its identity lives. Feeds the origin ladder
557
+ // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
558
+ // targets the backend it is bound to instead of whatever they last logged into.
559
+ // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
560
+ const siteScope = readSiteIdentity(siteDir).backend
552
561
  const siteBackend = await resolveSiteBackend(siteDir)
553
562
  const client = new BackendClient({
554
563
  originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
564
+ siteScope,
555
565
  siteBackend,
556
566
  token: tokenFlag,
557
567
  getToken: deps.getToken,
@@ -559,6 +569,17 @@ export async function pull(args = [], deps = {}) {
559
569
  args,
560
570
  command: 'Pulling'
561
571
  })
572
+
573
+ // ⛔ SCOPE CHECK — before the lanes read. `pull` WRITES the working tree from what it
574
+ // fetches, so a wrong-backend pull is not merely a failed read: it is the case that
575
+ // most needs stopping early.
576
+ const scope = assertSiteBackendScope(siteDir, client.origin)
577
+ if (!scope.ok) {
578
+ error(scope.message)
579
+ scope.hint.forEach(note)
580
+ return { exitCode: 1 }
581
+ }
582
+
562
583
  // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
563
584
  // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
564
585
  const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
@@ -72,6 +72,10 @@ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
72
72
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
73
73
  import { readOrgFlag } from '../utils/args.js'
74
74
  import { checkFlags } from '../utils/flag-guard.js'
75
+ import {
76
+ assertSiteBackendScope,
77
+ readSiteIdentity
78
+ } from '../utils/site-identity.js'
75
79
  import { confirm } from '../utils/interactive.js'
76
80
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
77
81
  import {
@@ -149,6 +153,11 @@ export async function push(args = [], deps = {}) {
149
153
  // Advisory only — warns and pushes. A malformed data block otherwise rides
150
154
  // the sync wire unchecked; see utils/conformance.js.
151
155
  await warnIfContentDoesNotConform(siteDir, { args })
156
+ // The project's own statement of where its identity lives. Feeds the origin ladder
157
+ // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
158
+ // targets the backend it is bound to instead of whatever they last logged into.
159
+ // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
160
+ const siteScope = readSiteIdentity(siteDir).backend
152
161
  const siteBackend = await resolveSiteBackend(siteDir)
153
162
  // One front door. The bearer is resolved lazily on first need (a non-local Model
154
163
  // read during the build, or the submit). Offline emit (--dry-run / -o) is fully
@@ -157,12 +166,37 @@ export async function push(args = [], deps = {}) {
157
166
  // references a Model the local foundation doesn't define.
158
167
  const client = new BackendClient({
159
168
  originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
169
+ siteScope,
160
170
  siteBackend,
161
171
  token: tokenFlag,
162
172
  args,
163
173
  command: 'Syncing'
164
174
  })
165
175
 
176
+ // ⛔ SCOPE CHECK — before anything is sent. A project whose stored identity was minted
177
+ // by a different backend cannot be pushed here: the uuids, the asset ids and the sync
178
+ // cache are all foreign at once. Runs after the client so it sees the RESOLVED origin
179
+ // (flag > env > deploy.yml > session), not the one we guessed.
180
+ //
181
+ // ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all. Its output is
182
+ // built from files on disk; the resolved origin is not an input to it, so a mismatch
183
+ // cannot make the artifact wrong — and refusing would break an operation this command
184
+ // deliberately keeps offline (the `!output && !dryRun` guards below are the same rule).
185
+ // The refusal even says "Sending them elsewhere is refused" over a run that sends
186
+ // nothing.
187
+ //
188
+ // `--dry-run` IS checked, and the asymmetry is the point: a dry run previews a real
189
+ // push, so when that push would be refused, saying so is the honest preview. Printing
190
+ // "would update content at <origin>" instead would preview something that cannot happen.
191
+ if (!output) {
192
+ const scope = assertSiteBackendScope(siteDir, client.origin)
193
+ if (!scope.ok) {
194
+ error(scope.message)
195
+ scope.hint.forEach(note)
196
+ return { exitCode: 1 }
197
+ }
198
+ }
199
+
166
200
  // WHO will own this site, if this push is the one that creates it. Resolved
167
201
  // before any lane runs, because both create paths below consume it and neither
168
202
  // should be reached with the question still open. A site that already exists
@@ -31,6 +31,7 @@ import { readFlagValue } from '../utils/args.js'
31
31
  import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
32
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
33
  import { checkFlags } from '../utils/flag-guard.js'
34
+ import { readSiteIdentity } from '../utils/site-identity.js'
34
35
 
35
36
  const c = {
36
37
  reset: '\x1b[0m',
@@ -109,6 +110,7 @@ export async function status(args = []) {
109
110
  const client = new BackendClient({
110
111
  originFlag:
111
112
  readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
113
+ siteScope: readSiteIdentity(siteDir).backend,
112
114
  siteBackend: await resolveSiteBackend(siteDir),
113
115
  token: readFlagValue(args, '--token') || undefined,
114
116
  args,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-24T14:55:17.484Z",
3
+ "generatedAt": "2026-08-24T22:21:24.819Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.25.2",
6
+ "version": "0.25.3",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -29,7 +29,7 @@
29
29
  "deps": []
30
30
  },
31
31
  "@uniweb/core": {
32
- "version": "0.11.2",
32
+ "version": "0.11.3",
33
33
  "path": "framework/core",
34
34
  "deps": [
35
35
  "@uniweb/semantic-parser",
@@ -37,7 +37,7 @@
37
37
  ]
38
38
  },
39
39
  "@uniweb/frame-bridge": {
40
- "version": "0.3.0",
40
+ "version": "0.3.1",
41
41
  "path": "framework/frame-bridge",
42
42
  "deps": []
43
43
  },
@@ -49,7 +49,7 @@
49
49
  ]
50
50
  },
51
51
  "@uniweb/kit": {
52
- "version": "0.13.3",
52
+ "version": "0.13.4",
53
53
  "path": "framework/kit",
54
54
  "deps": [
55
55
  "@uniweb/core",
@@ -105,9 +105,20 @@ export function getRegistryApiBaseUrl() {
105
105
  return fromCfg
106
106
  }
107
107
  }
108
- return 'https://uniweb.app'
108
+ return DEFAULT_BACKEND_ORIGIN
109
109
  }
110
110
 
111
+ /**
112
+ * The built-in backend origin — the last tier of the ladder, and the 98% case.
113
+ *
114
+ * Exported because `site.yml::$backend` is written ONLY when the backend is NOT this
115
+ * (`recordSiteBackend`, utils/site-identity.js). That test needs the *built-in* default,
116
+ * which `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier
117
+ * won, so on a machine with a session or a saved config it returns something else
118
+ * entirely. Two copies of the literal would silently disagree exactly there.
119
+ */
120
+ export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
121
+
111
122
  /**
112
123
  * Read workspace package globs.
113
124
  * Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The project's SYNC SCOPE — reading and asserting `site.yml::$backend`.
3
+ *
4
+ * A project that has synced holds backend-minted identity on **four** surfaces:
5
+ *
6
+ * · `site.yml::$uuid` the site-content entity
7
+ * · every collection record `$uuid` in its own source file (the pull-side match key)
8
+ * · `assets.json` local asset path → content-addressed id
9
+ * · `.uniweb/sync-cache.json` item uuids, hashes, base versions
10
+ *
11
+ * All four are meaningless against a different backend, and none of them says which
12
+ * backend it came from. `$backend` is the one fact that scopes all of them — which is
13
+ * why a mismatch is a **stop**, not a fallback: it does not mean "we guessed the wrong
14
+ * default", it means the entire stored surface is foreign.
15
+ *
16
+ * ⛔ **`$backend` is absent for the default backend, deliberately.** The 98% case keeps a
17
+ * clean `site.yml`, and an absent value reads as the default — correct both for a project
18
+ * written before this key existed and for one synced against the default. That inference
19
+ * is monotone: it is never worse than the pre-`$backend` behaviour, which recorded nothing
20
+ * at all.
21
+ *
22
+ * ⚠️ **Reads `site.yml` only, not the legacy `site.yaml`.** That matches every existing
23
+ * identity reader AND all three writers (`writeSiteEntityUuid`, `writeSiteOrg`,
24
+ * `clone.js`'s `seedYamlUuid`), so this module is consistent with what is on disk. It is
25
+ * also a KNOWN GAP, not an oversight: five other readers do accept `site.yaml`, and
26
+ * `upsertYamlScalar` creates a file when missing — so on a `site.yaml` project the writers
27
+ * produce a phantom `site.yml` holding nothing but identity. Closing that is its own change,
28
+ * and it has to move the readers and the writers together or it makes the split worse.
29
+ */
30
+
31
+ import { existsSync, readFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import yaml from 'js-yaml'
34
+ import { DEFAULT_BACKEND_ORIGIN } from './config.js'
35
+
36
+ // ⛔ NO STATIC `@uniweb/build` IMPORT IN THIS FILE — it is an OPTIONAL PEER, and this
37
+ // module is reachable from the CLI's startup graph (`index.js` imports `clone` eagerly,
38
+ // and `clone` imports this). A static import here makes `uniweb --version` die with
39
+ // ERR_MODULE_NOT_FOUND on a global install that has no build package:
40
+ //
41
+ // $ npm i -g uniweb && uniweb --version
42
+ // Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@uniweb/build'
43
+ //
44
+ // `test/smoke-startup.test.js` catches this structurally — and did, on the commit that
45
+ // introduced this file. The writer below therefore imports lazily, which also keeps the
46
+ // cost off every startup that never writes. Command modules may import build statically;
47
+ // anything a `utils/` leaf pulls in cannot.
48
+
49
+ /** A bare origin with no trailing slash, or null when unparseable. */
50
+ export function normalizeOrigin(value) {
51
+ if (typeof value !== 'string' || !value.trim()) return null
52
+ try {
53
+ return new URL(value).origin
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The project's identity trio, read from `site.yml`. Every field is independently
61
+ * optional — a project may be unsynced (no `uuid`), on the default backend (no
62
+ * `backend`), or personally owned (no `org`).
63
+ *
64
+ * ⚠️ This is NOT yet the single accessor for `$uuid`. Nine other places still read it
65
+ * directly; they are correct as they stand and were deliberately left alone when the
66
+ * scope check was centralized here (a per-read accessor was solving a coupling problem
67
+ * that one guard solves better). Consolidating them is separable cleanup.
68
+ *
69
+ * @param {string} siteDir
70
+ * @returns {{ uuid: string|null, backend: string|null, org: string|null }}
71
+ */
72
+ export function readSiteIdentity(siteDir) {
73
+ const path = join(siteDir, 'site.yml')
74
+ if (!existsSync(path)) return { uuid: null, backend: null, org: null }
75
+ let y
76
+ try {
77
+ y = yaml.load(readFileSync(path, 'utf8'))
78
+ } catch {
79
+ // Unreadable or malformed — report "nothing recorded" rather than throwing. Every
80
+ // caller here is a guard or a default; none of them should be the reason a command
81
+ // dies, and a malformed site.yml has its own, better error elsewhere.
82
+ return { uuid: null, backend: null, org: null }
83
+ }
84
+ if (!y || typeof y !== 'object') return { uuid: null, backend: null, org: null }
85
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
86
+ return {
87
+ uuid: str(y.$uuid),
88
+ backend: normalizeOrigin(y.$backend),
89
+ org: str(y.$org)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The backend this project is bound to, with the default filled in.
95
+ *
96
+ * Use this rather than `readSiteIdentity().backend` wherever an absent value means the
97
+ * default — which is everywhere except a "was it recorded?" question.
98
+ *
99
+ * ⛔ **NOT for the origin ladder.** `resolveBackendOrigin`'s `siteScope` tier must receive
100
+ * the RAW `readSiteIdentity().backend`, which is null when nothing was recorded. Handing
101
+ * it this defaulted value would make the tier fire for EVERY project, and since it sits
102
+ * above the session it would shadow `uniweb login --backend <local>` on any project
103
+ * without `$backend` — i.e. break local development for the 98% that never record one.
104
+ * "Absent means the default" is the right rule for a comparison and the wrong one for a
105
+ * precedence chain, where absent has to mean "defer to the next tier".
106
+ *
107
+ * @param {string} siteDir
108
+ * @returns {string} a bare origin
109
+ */
110
+ export function resolveSiteScope(siteDir) {
111
+ return readSiteIdentity(siteDir).backend || DEFAULT_BACKEND_ORIGIN
112
+ }
113
+
114
+ /**
115
+ * Record the sync scope on a site that has just been created or seeded.
116
+ *
117
+ * A no-op for the default backend (see the ⛔ above) and a no-op when the value is
118
+ * already what we would write, so a re-push never dirties `git status`.
119
+ *
120
+ * @param {string} siteDir
121
+ * @param {string} origin - the backend the site was just created on
122
+ * @param {object} [deps]
123
+ * @param {() => Promise<object>} [deps.loadUwx] - injected module loader. Exists so the
124
+ * two failure branches below can be TESTED: both are about which `@uniweb/build`
125
+ * happens to be on disk, which a test cannot otherwise vary — and the too-old
126
+ * branch is precisely the one that used to fail silently.
127
+ * @returns {Promise<string|null>} the origin recorded, or null when nothing was written
128
+ */
129
+ export async function recordSiteBackend(siteDir, origin, deps = {}) {
130
+ const norm = normalizeOrigin(origin)
131
+ if (!norm || norm === DEFAULT_BACKEND_ORIGIN) return null
132
+ if (readSiteIdentity(siteDir).backend === norm) return null
133
+ const loadUwx = deps.loadUwx || (() => import('@uniweb/build/uwx'))
134
+ let mod
135
+ try {
136
+ // Lazy by necessity, not by taste — see the header.
137
+ mod = await loadUwx()
138
+ } catch {
139
+ // No `@uniweb/build` at all. It is an OPTIONAL peer, so this is a supported
140
+ // configuration and not worth a word — the scope simply goes unrecorded, which is
141
+ // the behaviour everyone had before this key existed.
142
+ return null
143
+ }
144
+
145
+ // ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
146
+ // above. `@uniweb/build` gained `writeSiteBackend` in 0.25.3; against an older copy the
147
+ // import SUCCEEDS and the export is `undefined`, so calling it throws a TypeError that
148
+ // a blanket catch would swallow — leaving the scope silently unrecorded on a project
149
+ // that will later be stopped by the guard and told to add `$backend` by hand. Since the
150
+ // CLI declares build as a peer at a caret range, a lockfile pinned to an older patch
151
+ // reaches exactly this state on a CLI-only upgrade. Say so once.
152
+ if (typeof mod.writeSiteBackend !== 'function') {
153
+ console.error(
154
+ `\x1b[33m⚠\x1b[0m This project's @uniweb/build is too old to record which backend it syncs with — upgrade it (\`npx uniweb@latest update\`), or add \`$backend: ${norm}\` to site.yml.`
155
+ )
156
+ return null
157
+ }
158
+
159
+ try {
160
+ mod.writeSiteBackend(siteDir, norm)
161
+ return norm
162
+ } catch {
163
+ // Same rule as `recordSiteOrg`: the uuid is the load-bearing write. Losing the scope
164
+ // note must never fail a create that already succeeded on the backend — the guard
165
+ // degrades to the pre-`$backend` behaviour, which is what everyone had until now.
166
+ return null
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Refuse to act on a site whose stored identity belongs to a different backend.
172
+ *
173
+ * ⭐ **A stop, not a warning.** `BackendClient.token()` already carries an advisory
174
+ * origin-mismatch guard for the SESSION (wrong bearer → a rejected request, recoverable).
175
+ * This is the third leg and it is categorically worse: the stored surface is foreign, so
176
+ * proceeding sends one backend's identity to another. The observed outcomes are a 404
177
+ * whose stock advice destroys the binding, and — on the record lane — a hard refusal
178
+ * (*"item uuids are globally unique; cross-entity move is not supported"*).
179
+ *
180
+ * Silent for an unsynced project (nothing stored yet ⇒ nothing to be foreign) and for a
181
+ * project on the default backend with no explicit override.
182
+ *
183
+ * @param {string} siteDir
184
+ * @param {string} origin - the backend this command resolved to
185
+ * @returns {{ ok: true } | { ok: false, message: string, hint: string[] }}
186
+ */
187
+ export function assertSiteBackendScope(siteDir, origin) {
188
+ const { uuid, backend } = readSiteIdentity(siteDir)
189
+ // Nothing minted here yet — a create is exactly what is supposed to happen next, and
190
+ // it will record the scope itself. Checking a project with no stored identity would
191
+ // reject the first push of every new site.
192
+ if (!uuid) return { ok: true }
193
+
194
+ const target = normalizeOrigin(origin)
195
+ const bound = backend || DEFAULT_BACKEND_ORIGIN
196
+ if (!target || target === bound) return { ok: true }
197
+
198
+ const hint = [
199
+ `site.yml::$uuid (${uuid}) was minted by ${bound}, and the record uuids, assets.json`,
200
+ 'and .uniweb/sync-cache.json are scoped to it too. Sending them elsewhere is refused.',
201
+ '',
202
+ `To work with ${bound}: uniweb login --backend ${bound} (or pass --backend ${bound})`,
203
+ `To move this project to ${target}, it becomes a NEW site there — clear $uuid, $org and`,
204
+ '$backend from site.yml and delete .uniweb/ and assets.json first.'
205
+ ]
206
+
207
+ // ⚠️ THE ONE FALSE POSITIVE, and it prints its own fix.
208
+ //
209
+ // `$backend` is omitted for the default backend, so an ABSENT value is ambiguous: it
210
+ // means "the default" for anything written under this scheme, and "unknown" for a
211
+ // project that synced to a non-default backend BEFORE the key existed. This branch
212
+ // reads absent as the default, so that second project is stopped and told it belongs
213
+ // to a backend it never used.
214
+ //
215
+ // Accepted deliberately rather than softened to a warning, because the alternative is
216
+ // worse in the case that matters: warning-on-absent would leave every DEFAULT-backend
217
+ // project — the 98% — unprotected forever, since those never record the key. Stopping
218
+ // on a guess is only tolerable when the guess prints the one-line correction, so it
219
+ // does. (The population at risk is also near-empty by construction: pre-`$backend`
220
+ // projects on a non-default backend, at a moment when no such backend is running.)
221
+ if (!backend) {
222
+ hint.push(
223
+ '',
224
+ `If this project actually syncs with ${target}, nothing recorded that — say so once:`,
225
+ ` add $backend: ${target} to site.yml, and this stops asking.`
226
+ )
227
+ }
228
+
229
+ return {
230
+ ok: false,
231
+ message: `This project's stored identity belongs to ${bound}, but this command targets ${target}.`,
232
+ hint
233
+ }
234
+ }