uniweb 0.59.2 → 0.61.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.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * uniweb pull — bring the backend's copy of a site back to canonical files.
3
3
  *
4
- * The read-side mirror of `uniweb push`. The project holds exactly one identity —
5
- * `site.yml::$uuid` (the site-content entity) — and BOTH pull lanes are keyed by it
4
+ * The read-side mirror of `uniweb push`. The project's identity on a backend — its
5
+ * site-content uuid, in that backend's section of `sync.json` — keys BOTH pull lanes
6
6
  * (the backend owns the site's `@uniweb/folder` and resolves it from the site-content
7
7
  * uuid, so the framework never holds a folder uuid). It GETs the two lanes and projects
8
8
  * the returned documents back to files via the framework's projection layer
@@ -41,12 +41,12 @@
41
41
  * uniweb pull --merge Three-way merge local changes with the backend's
42
42
  * uniweb pull --force Pull over uncommitted local changes (discards them)
43
43
  * uniweb pull --dry-run Report what it would GET; write nothing
44
- * uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
45
44
  *
46
- * Backend: via BackendClient (the content + folder pull lanes), both keyed by
47
- * `site.yml::$uuid`. Origin from
48
- * UNIWEB_REGISTER_URL > the local default.
49
- * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
45
+ * The content + folder pull lanes are both keyed by the site's uuid on that backend
46
+ * (sync.json).
47
+ * Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
48
+ * (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
49
+ * No `--backend` or `--token`: switching and signing in are `uniweb login`.
50
50
  *
51
51
  * A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
52
52
  * clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
@@ -69,6 +69,7 @@ import { join, dirname, relative } from 'node:path'
69
69
  import yaml from 'js-yaml'
70
70
  import { downloadMissingAssets } from '../backend/asset-download.js'
71
71
  import {
72
+ readBackendState,
72
73
  siteContentDocumentToProject,
73
74
  recordsToProject,
74
75
  readZip,
@@ -98,14 +99,10 @@ import {
98
99
  } from '../utils/git.js'
99
100
  import { isNonInteractive } from '../utils/interactive.js'
100
101
  import { BackendClient } from '../backend/client.js'
101
- import {
102
- resolveSiteDir as defaultResolveSiteDir,
103
- resolveSiteBackend
104
- } from './deploy.js'
102
+ import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
105
103
  import { checkFlags } from '../utils/flag-guard.js'
106
104
  import {
107
- assertSiteBackendScope,
108
- readSiteIdentity
105
+ syncedElsewhere
109
106
  } from '../utils/site-identity.js'
110
107
 
111
108
  const FOLDER_MODEL = '@uniweb/folder'
@@ -188,7 +185,8 @@ function readYamlUuid(filePath) {
188
185
  } catch (err) {
189
186
  return { uuid: null, unparsed: err.message.split('\n')[0] }
190
187
  }
191
- return { uuid: typeof obj?.$uuid === 'string' ? obj.$uuid : null }
188
+ // Only whether site.yml parses. The uuid itself lives in sync.json now.
189
+ return { uuid: null }
192
190
  }
193
191
 
194
192
  // Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
@@ -540,7 +538,6 @@ export async function pull(args = [], deps = {}) {
540
538
  const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
541
539
 
542
540
  const dryRun = args.includes('--dry-run')
543
- const tokenFlag = flagValue(args, '--token')
544
541
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
545
542
  const noRecords =
546
543
  args.includes('--no-records') || args.includes('--content-only')
@@ -579,35 +576,28 @@ export async function pull(args = [], deps = {}) {
579
576
  const blocked = await checkWorkingTree(siteDir, args)
580
577
  if (blocked) return blocked
581
578
  }
582
- // The project's own statement of where its identity lives. Feeds the origin ladder
583
- // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
584
- // targets the backend it is bound to instead of whatever they last logged into.
585
- // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
586
- const siteScope = readSiteIdentity(siteDir).backend
587
- const siteBackend = await resolveSiteBackend(siteDir)
579
+ // ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
580
+ // backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
581
+ // override — outranks it, and nothing talks to a backend the user is not logged in to:
582
+ // logged in nowhere, the origin is the default backend and the first request asks for
583
+ // that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
584
+ // ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
585
+ // NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
586
+ // be reached: *"We do not allow any communication with backend if the user is not
587
+ // logged into a backend."*
588
588
  const client = new BackendClient({
589
- originFlag: flagValue(args, '--backend'),
590
- siteScope,
591
- siteBackend,
592
- token: tokenFlag,
593
589
  getToken: deps.getToken,
594
590
  fetchImpl: deps.fetch,
595
591
  args,
596
592
  command: 'Pulling'
597
593
  })
598
594
 
599
- // ⛔ SCOPE CHECK — before the lanes read. `pull` WRITES the working tree from what it
600
- // fetches, so a wrong-backend pull is not merely a failed read: it is the case that
601
- // most needs stopping early.
602
- const scope = assertSiteBackendScope(siteDir, client.origin)
603
- if (!scope.ok) {
604
- error(scope.message)
605
- scope.hint.forEach(note)
606
- return { exitCode: 1 }
607
- }
595
+ // ⭐ No scope check any more — and none is needed. This project's identity on
596
+ // `client.origin` is read from that origin's own section of sync.json, so a
597
+ // wrong-backend pull cannot happen: it would find no uuid and stop below.
608
598
 
609
- // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
610
- // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
599
+ // One identity per site per backend. Both lanes (content + folder) are keyed by it —
600
+ // the backend resolves the site's `@uniweb/folder` from this uuid.
611
601
  const siteYmlPath = join(siteDir, 'site.yml')
612
602
  const identity = readYamlUuid(siteYmlPath)
613
603
 
@@ -621,11 +611,28 @@ export async function pull(args = [], deps = {}) {
621
611
  return { exitCode: 1 }
622
612
  }
623
613
 
624
- const siteContentUuid = identity.uuid
614
+ // ⛔ FROM sync.json, FOR THIS BACKEND. This read `identity.uuid` — site.yml's `$uuid`
615
+ // — and kept doing so after step 4 moved the key, so EVERY pull of a synced project
616
+ // answered "Nothing to pull — this project has no $uuid yet". site.yml is still
617
+ // parsed above, because a file that will not parse is its own error.
618
+ const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
625
619
 
626
620
  if (!siteContentUuid) {
621
+ // Following the login can land on a backend with no site for this project while it
622
+ // has one elsewhere — say where, rather than only "push first", which would create a
623
+ // second site. Not when UNIWEB_REGISTER_URL chose the backend: that is a decision.
624
+ const known = !process.env.UNIWEB_REGISTER_URL
625
+ ? syncedElsewhere(siteDir, client.origin)
626
+ : null
627
+ if (known) {
628
+ info(`Nothing to pull — this project has no site on ${client.origin}.`)
629
+ note(
630
+ `Its ${known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`}. To pull from there: uniweb login --backend <url>`
631
+ )
632
+ return { exitCode: 0 }
633
+ }
627
634
  info(
628
- 'Nothing to pull — this project has no $uuid yet. Run `uniweb push` first.'
635
+ `Nothing to pull — this project has not synced with ${client.origin} yet. Run \`uniweb push\` first.`
629
636
  )
630
637
  return { exitCode: 0 }
631
638
  }
@@ -652,7 +659,7 @@ export async function pull(args = [], deps = {}) {
652
659
  res = await doRequest()
653
660
  } catch (err) {
654
661
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
655
- note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
662
+ note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
656
663
  return null
657
664
  }
658
665
  if (res.status === 404) {
@@ -667,7 +674,7 @@ export async function pull(args = [], deps = {}) {
667
674
  error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
668
675
  if (res.status === 401 || res.status === 403)
669
676
  note(
670
- "Credentials weren't accepted — supply a bearer with --token <bearer>."
677
+ "Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
671
678
  )
672
679
  return null
673
680
  }
@@ -678,8 +685,8 @@ export async function pull(args = [], deps = {}) {
678
685
  // Bank the per-entity staleness tokens this lane carried, so the next push
679
686
  // is gated against the state we just took. A 304 skips this — correctly:
680
687
  // unchanged upstream means the token we already hold is still current.
681
- mergeBaseVersions(siteDir, readPullVersions(buf))
682
- mergeItemBaseVersions(siteDir, readPullItemVersions(buf))
688
+ mergeBaseVersions(siteDir, client.origin, readPullVersions(buf))
689
+ mergeItemBaseVersions(siteDir, client.origin, readPullItemVersions(buf))
683
690
  return { docs, etag }
684
691
  } catch (err) {
685
692
  error(`Could not read the ${label} response: ${err.message}`)
@@ -721,14 +728,14 @@ export async function pull(args = [], deps = {}) {
721
728
  // byte-identical to it, so the old local base no longer describes anything.
722
729
  // The next push re-establishes it; until then our side reports as unknown,
723
730
  // which is honest rather than wrong.
724
- writeUnitBases(siteDir, { remote: computeUnitHashes(siteDoc), local: {} })
731
+ writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
725
732
  // Per-item identity for the next push. Without it the backend reads our
726
733
  // records as new and re-mints every page and section row.
727
- writeItemUuids(siteDir, collectUnitUuids(siteDoc))
734
+ writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
728
735
  // The collections section's identity has no file to live in either — same
729
736
  // reason, same remedy, keyed by name. A pull is the other route by which a
730
737
  // copy can recover it (see readQueryUuids).
731
- writeQueryUuids(siteDir, collectQueryUuids(siteDoc))
738
+ writeQueryUuids(siteDir, client.origin, collectQueryUuids(siteDoc))
732
739
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
733
740
  // entry, and the projection reads that map to put authored paths back. Run
734
741
  // after, and this pull's new assets would project as URLs and only restore
@@ -795,6 +802,9 @@ export async function pull(args = [], deps = {}) {
795
802
  const report = siteContentDocumentToProject({
796
803
  document: siteDoc,
797
804
  siteRoot: siteDir,
805
+ // Which backend's asset ids to read back as the author's own paths. Without
806
+ // it a pull leaves every image pointing at a backend route.
807
+ backend: client.origin,
798
808
  prune,
799
809
  keepAuthoredFoundation
800
810
  })
@@ -833,18 +843,22 @@ export async function pull(args = [], deps = {}) {
833
843
  }
834
844
  // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
835
845
  // `$model` names the pool folder — not by any query that happens to select
836
- // it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
837
- // model the producer resolved to `@org/x` is placed back where the author
838
- // wrote it.
846
+ // it. `recordsToProject` reads the site's org itself (this backend's, from
847
+ // `sync.json`), so a `@/x` model the producer resolved to `@org/x` is placed
848
+ // back where the author wrote it.
839
849
  const report = recordsToProject({
840
850
  folderDoc,
841
851
  recordDocs,
842
852
  siteRoot: siteDir,
843
853
  opts: {
854
+ // Whose record map to read and extend, and whose org to place models under.
855
+ backend: client.origin,
844
856
  resolveDeclaration: (name) => declByModel.get(name) || null
845
857
  }
846
858
  })
847
859
  if (report.records === 'updated') info('Wrote records.yml')
860
+ // The backend's folder has no sub-folders, and records.yml holds nothing else.
861
+ else if (report.records === 'removed') info('Removed records.yml — the folder has no sub-folders')
848
862
  records += report.placed.length + report.updated.length
849
863
  for (const s of report.skipped)
850
864
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -905,7 +919,7 @@ export async function pull(args = [], deps = {}) {
905
919
  // wrong content, and must not fail a pull whose files are already written.
906
920
  if (!dryRun) {
907
921
  try {
908
- await rebankSyncHashes(siteDir)
922
+ await rebankSyncHashes(siteDir, client.origin)
909
923
  } catch (err) {
910
924
  note(`! could not re-bank the sync cache: ${err.message}`)
911
925
  note(' The next push will re-send content that is already current.')
@@ -5,15 +5,16 @@
5
5
  * - folder lane → one `@uniweb/folder` + the collection-record entities it
6
6
  * references (the dynamic half; the `$ref` closure rides together).
7
7
  *
8
- * Each entity is an entity-content document (`$id` + `$model` + sections). The site
9
- * holds exactly one identity: `site.yml::$uuid` (the site-content entity). A first
10
- * push has none — it CREATEs the site (uuid-less), the backend mints + adopts it
11
- * and returns the new uuid, which `push` records into `site.yml`. Later pushes
8
+ * Each entity is an entity-content document (`$id` + `$model` + sections). The site's
9
+ * identity on a backend is its site-content uuid, in that backend's section of
10
+ * `sync.json`. A first push to a backend has none — it CREATEs the site (uuid-less),
11
+ * the backend mints + adopts it and returns the new uuid, which `push` records in
12
+ * `sync.json`. Later pushes
12
13
  * UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
13
14
  * the backend owns the site's `@uniweb/folder`, so the framework never holds a
14
15
  * folder uuid. Records still round-trip their own `$uuid`
15
16
  * (back-filled into their source files). site-content items carry a per-item `$uuid`
16
- * too, stamped at emit from the identity cache rather than from author files — without
17
+ * too, stamped at emit from `sync.json` rather than from author files — without
17
18
  * it the backend reads every record as new and recreates every page and section row.
18
19
  * Push-only, and gated on the backend's per-entity `version`
19
20
  * (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
@@ -29,15 +30,14 @@
29
30
  * uniweb push --org @org Own the new site under @org (alias: --as-org).
30
31
  * Read only on the FIRST push of a site — it
31
32
  * decides which org owns it, and whose storage
32
- * its assets are charged to. Recorded as
33
- * `site.yml::$org` and replayed after that.
33
+ * its assets are charged to. Recorded in
34
+ * `sync.json` and replayed after that.
34
35
  * Without it, you are asked once.
35
36
  * uniweb push --personal Own the new site personally, deliberately.
36
37
  * Sends NO `as_org` — the same wire as before
37
38
  * this prompt existed. First push only.
38
39
  * uniweb push --dry-run Report what would be pushed; submit nothing
39
40
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
40
- * uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
41
41
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
42
42
  * uniweb push --all Send every record (bypass the changed-only cache)
43
43
  * uniweb push --force Overwrite upstream changes (drop the staleness gate)
@@ -49,9 +49,9 @@
49
49
  * backend's reconcile deletes items absent from the package, so an author's NEW page
50
50
  * would be hard-deleted). `--force` omits the token and restores last-push-wins.
51
51
  *
52
- * Backend: via BackendClient (the content + folder sync lanes). Origin from
53
- * UNIWEB_REGISTER_URL > the local default.
54
- * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
52
+ * Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
53
+ * (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
54
+ * No `--backend` or `--token`: switching and signing in are `uniweb login`.
55
55
  *
56
56
  * The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
57
57
  * send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
@@ -63,17 +63,18 @@ import { readFileSync, writeFileSync } from 'node:fs'
63
63
  import { join, resolve } from 'node:path'
64
64
  import yaml from 'js-yaml'
65
65
  import { emitSyncPackages } from '@uniweb/build/uwx'
66
+ import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
66
67
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
67
- import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
68
+ import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
68
69
  import { BackendClient } from '../backend/client.js'
69
- import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
70
+ import { resolveSiteDir } from './deploy.js'
70
71
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
71
72
  import { reportSchemalessQueries } from '../utils/schemaless-report.js'
72
73
  import { readOrgFlag } from '../utils/args.js'
73
74
  import { checkFlags } from '../utils/flag-guard.js'
74
75
  import {
75
- assertSiteBackendScope,
76
- readSiteIdentity
76
+ syncedElsewhere,
77
+ describeSyncedElsewhere
77
78
  } from '../utils/site-identity.js'
78
79
  import { confirm } from '../utils/interactive.js'
79
80
  import { guardEmptyRecords } from '../utils/records-guard.js'
@@ -141,7 +142,6 @@ export async function push(args = [], deps = {}) {
141
142
  }
142
143
  const dryRun = args.includes('--dry-run')
143
144
  const output = flagValue(args, '-o') || flagValue(args, '--output')
144
- const tokenFlag = flagValue(args, '--token')
145
145
  const foundationDir = flagValue(args, '--foundation')
146
146
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
147
147
  // --force drops the optimistic-concurrency precondition, making the push
@@ -156,50 +156,64 @@ export async function push(args = [], deps = {}) {
156
156
  // Advisory only — warns and pushes. A malformed data block otherwise rides
157
157
  // the sync wire unchecked; see utils/conformance.js.
158
158
  await warnIfContentDoesNotConform(siteDir, { args })
159
- // The project's own statement of where its identity lives. Feeds the origin ladder
160
- // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
161
- // targets the backend it is bound to instead of whatever they last logged into.
162
- // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
163
- const siteScope = readSiteIdentity(siteDir).backend
164
- const siteBackend = await resolveSiteBackend(siteDir)
159
+ // ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
160
+ // backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
161
+ // override — outranks it, and nothing talks to a backend the user is not logged in to:
162
+ // logged in nowhere, the origin is the default backend and the first request asks for
163
+ // that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
164
+ // ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
165
+ // NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
166
+ // be reached: *"We do not allow any communication with backend if the user is not
167
+ // logged into a backend."*
165
168
  // One front door. The bearer is resolved lazily on first need (a non-local Model
166
169
  // read during the build, or the submit). Offline emit (--dry-run / -o) is fully
167
170
  // offline: it never submits, and its Model resolver never reads from the backend
168
171
  // (the `offline` flag below), so it never authenticates — even when a collection
169
172
  // references a Model the local foundation doesn't define.
170
173
  const client = new BackendClient({
171
- originFlag: flagValue(args, '--backend'),
172
- siteScope,
173
- siteBackend,
174
- token: tokenFlag,
175
174
  args,
176
175
  command: 'Syncing'
177
176
  })
178
177
 
179
- // ⛔ SCOPE CHECK — before anything is sent. A project whose stored identity was minted
180
- // by a different backend cannot be pushed here: the uuids, the asset ids and the sync
181
- // cache are all foreign at once. Runs after the client so it sees the RESOLVED origin
182
- // (flag > env > deploy.yml > session), not the one we guessed.
178
+ // ⛔ A COPY OF ANOTHER PROJECT — before anything is sent. A plain directory copy
179
+ // carries sync.json, so it holds the ORIGINAL's site on this backend and its push
180
+ // would update that site (see utils/site-copies.js for why the signal is exact).
181
+ // Refused, not warned: a warning printed while the push goes ahead is printed over
182
+ // the damage. Runs after the client so it checks the RESOLVED origin.
183
183
  //
184
- // ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all. Its output is
185
- // built from files on disk; the resolved origin is not an input to it, so a mismatch
186
- // cannot make the artifact wrong — and refusing would break an operation this command
187
- // deliberately keeps offline (the `!output && !dryRun` guards below are the same rule).
188
- // The refusal even says "Sending them elsewhere is refused" over a run that sends
189
- // nothing.
184
+ // ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all — there is no
185
+ // site for it to update, and refusing would break an operation this command keeps
186
+ // offline (the `!output && !dryRun` guards below are the same rule).
190
187
  //
191
188
  // `--dry-run` IS checked, and the asymmetry is the point: a dry run previews a real
192
- // push, so when that push would be refused, saying so is the honest preview. Printing
193
- // "would update content at <origin>" instead would preview something that cannot happen.
189
+ // push, so when that push would be refused, saying so is the honest preview.
190
+ //
191
+ // *(This spot held the backend SCOPE CHECK, `assertSiteBackendScope`, until
192
+ // 2026-09-20 — deleted when identity became per-backend and a foreign backend's
193
+ // uuids stopped being reachable at all.)*
194
194
  if (!output) {
195
- const scope = assertSiteBackendScope(siteDir, client.origin)
196
- if (!scope.ok) {
197
- error(scope.message)
198
- scope.hint.forEach(note)
195
+ const copies = findSiteCopies(siteDir, client.origin)
196
+ if (copies.length) {
197
+ const said = describeSiteCopies(copies, client.origin, 'push')
198
+ error(said.headline)
199
+ for (const line of said.lines) note(line)
199
200
  return { exitCode: 1 }
200
201
  }
201
202
  }
202
203
 
204
+ // ⚠️ Logged in to a backend where this project has no site, while it has one
205
+ // elsewhere: following the login CREATES a second site. Say so before the owner
206
+ // question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
207
+ // created) and not when UNIWEB_REGISTER_URL chose the backend — that is a decision.
208
+ if (!output && !process.env.UNIWEB_REGISTER_URL) {
209
+ const known = syncedElsewhere(siteDir, client.origin)
210
+ if (known) {
211
+ const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'push')
212
+ info(headline)
213
+ for (const line of rest) note(line)
214
+ }
215
+ }
216
+
203
217
  // WHO will own this site, if this push is the one that creates it. Resolved
204
218
  // before any lane runs, because both create paths below consume it and neither
205
219
  // should be reached with the question still open. A site that already exists
@@ -298,18 +312,18 @@ export async function push(args = [], deps = {}) {
298
312
  //
299
313
  // Still skipped for an offline emit: `-o` / `--dry-run` must not mutate project state.
300
314
  if (!output && !dryRun) {
301
- const dropped = clearRemoteSyncStateIfUnbound(siteDir)
315
+ const dropped = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
302
316
  if (dropped.length) {
303
317
  note(
304
318
  `Cleared stale sync state from a previous site (${dropped.join(', ')}).`
305
319
  )
306
320
  }
307
- const stale = dropSiteBoundValues(siteDir)
321
+ const stale = dropSiteBoundValues(siteDir, client.origin)
308
322
  if (stale.length) {
309
323
  note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
310
324
  }
311
325
  }
312
- const priorHashes = readSyncCache(siteDir)
326
+ const priorHashes = readSyncCache(siteDir, client.origin)
313
327
  // Per-item identity, without which the backend reads every record as new and
314
328
  // recreates every page and section row.
315
329
  //
@@ -329,8 +343,8 @@ export async function push(args = [], deps = {}) {
329
343
  // The upload runs before `ensureItemUuids`, and the reason once recorded here —
330
344
  // "BEFORE `ensureItemUuids`, which mints uuids on the backend, so a refusal
331
345
  // leaves nothing minted" — is FALSE. `ensureItemUuids` mints nothing: it reads a
332
- // local cache, and if that is empty it reads `site.yml::$uuid` and returns
333
- // immediately when there is none (the first-push case). Its only backend call is
346
+ // local map, and if that is empty it reads this backend's site uuid from sync.json
347
+ // and returns immediately when there is none (the first-push case). Its only backend call is
334
348
  // a GET (`pullSiteContent`); its only write is a local file. So this ordering is
335
349
  // not protecting what that comment claimed, and is under review — an upload is a
336
350
  // durable, metered write, which makes uploading first the thing that leaves bytes
@@ -374,12 +388,12 @@ export async function push(args = [], deps = {}) {
374
388
  // It also has to be this emit that carries `assetRewrite` below: the push cache
375
389
  // stores hashes of the REWRITTEN content, so the emit compared against it must
376
390
  // rewrite too, or every entity reads as changed forever.
377
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
378
- // destructive — a placeholder file, created meaning to fill it in — so the count
379
- // is reported and confirmed before anything is sent. The format stays honest;
380
- // the asking happens here.
391
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
392
+ // is destructive — a directory emptied by accident, or kept with only a
393
+ // placeholder — so the count is reported and confirmed before anything is sent.
394
+ // The format stays honest; the asking happens here.
381
395
  if (!dryRun) {
382
- const guard = await guardEmptyRecords({ siteDir, args, warn, note })
396
+ const guard = await guardEmptyRecords({ siteDir, backend: client.origin, args, warn, note })
383
397
  if (!guard.ok) return { exitCode: 1 }
384
398
  }
385
399
 
@@ -389,8 +403,9 @@ export async function push(args = [], deps = {}) {
389
403
  let mediaRefs = []
390
404
  try {
391
405
  const probe = await emitSyncPackages(siteDir, {
406
+ backend: client.origin,
392
407
  // Placement identity for the folder — see writeFolderItemUuids.
393
- folderItemUuids: readFolderItemUuids(siteDir),
408
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
394
409
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
395
410
  ...(asOrg ? { org: asOrg } : {}),
396
411
  ...(foundationDir ? { foundationDir } : {}),
@@ -458,10 +473,10 @@ export async function push(args = [], deps = {}) {
458
473
  )
459
474
  // Record identity in the COMMITTED map. Merge, never replace: this push
460
475
  // carries only the refs its content touched.
461
- const rec = updateAssetMap(siteDir, ids)
476
+ const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
462
477
  if (rec.written) {
463
478
  note(
464
- `${ASSET_MAP_FILE}: ${rec.added.length} added, ${rec.changed.length} changed — commit it`
479
+ `${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
465
480
  )
466
481
  }
467
482
  } catch (err) {
@@ -482,16 +497,17 @@ export async function push(args = [], deps = {}) {
482
497
 
483
498
  const itemUuids =
484
499
  output || dryRun
485
- ? readItemUuids(siteDir)
500
+ ? readItemUuids(siteDir, client.origin)
486
501
  : await ensureItemUuids({ client, siteDir, note })
487
502
  let pkg
488
503
  try {
489
504
  pkg = await emitSyncPackages(siteDir, {
505
+ backend: client.origin,
490
506
  // Placement identity for the folder — see writeFolderItemUuids.
491
- folderItemUuids: readFolderItemUuids(siteDir),
507
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
492
508
  // Identity for the `queries` section — see readQueryUuids. Keyed by
493
509
  // name, because a declaration has no file for a path-keyed map to hold.
494
- queryUuids: readQueryUuids(siteDir),
510
+ queryUuids: readQueryUuids(siteDir, client.origin),
495
511
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
496
512
  ...(asOrg ? { org: asOrg } : {}),
497
513
  ...(foundationDir ? { foundationDir } : {}),
@@ -513,8 +529,8 @@ export async function push(args = [], deps = {}) {
513
529
  ...(force
514
530
  ? {}
515
531
  : {
516
- baseVersions: readBaseVersions(siteDir),
517
- itemBaseVersions: readItemBaseVersions(siteDir)
532
+ baseVersions: readBaseVersions(siteDir, client.origin),
533
+ itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
518
534
  }),
519
535
  ...(assetRewrite ? { assetRewrite } : {}),
520
536
  ...(assetIds ? { assetIds } : {})
@@ -46,6 +46,8 @@ import {
46
46
  headProvenance
47
47
  } from '../utils/git.js'
48
48
  import { probeUnpushed } from '../backend/site-sync.js'
49
+ import { resolveBackendOrigin } from '../backend/client.js'
50
+ import { readBackendState } from '@uniweb/build/uwx'
49
51
  import { checkFlags } from '../utils/flag-guard.js'
50
52
 
51
53
  const c = {
@@ -65,28 +67,16 @@ const say = {
65
67
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
66
68
  }
67
69
 
68
- // Forward a flag AND its value to the delegated verb. `--backend http://x` is two
69
- // argv entries, so a naive filter passes the flag and drops the URL — leaving the
70
- // delegated pull pointed at the default backend while the user believes they
71
- // overrode it. `--backend=http://x` is one entry and passes through as-is.
72
- function collectPassthrough(args, names) {
73
- const out = []
74
- for (let i = 0; i < args.length; i++) {
75
- const a = args[i]
76
- const name = names.find((n) => a === n || a.startsWith(`${n}=`))
77
- if (!name) continue
78
- out.push(a)
79
- if (a === name && args[i + 1] && !args[i + 1].startsWith('-'))
80
- out.push(args[++i])
81
- }
82
- return out
83
- }
84
-
85
- // A site is backend-synced once it has an identity to pull by.
86
- function siteContentUuid(siteDir) {
70
+ // A site is backend-synced once it has an identity to pull by — on THIS backend,
71
+ // from sync.json. It read `site.yml::$uuid`, so after step 4 refresh treated every
72
+ // project as unsynced and skipped the backend half of its check.
73
+ //
74
+ // ⛔ `backend` is the one the delegated pull will use: the backend the user is logged in
75
+ // to. (For part of 2026-09-21, `refresh --backend X` checked one backend's sync state and
76
+ // pulled from another; the flag is gone from the backend verbs since.)
77
+ function siteContentUuid(siteDir, backend) {
87
78
  try {
88
- const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
89
- return typeof y?.$uuid === 'string' ? y.$uuid : null
79
+ return readBackendState(siteDir, backend).site?.uuid || null
90
80
  } catch {
91
81
  return null
92
82
  }
@@ -155,20 +145,21 @@ export async function refresh(args = [], deps = {}) {
155
145
  }
156
146
 
157
147
  // ── 2. the backend ────────────────────────────────────────────────────────
148
+ // The backend the delegated pull talks to — the one you are logged in to
149
+ // (resolveBackendOrigin). Everything below asks about THAT one.
150
+ const backend = resolveBackendOrigin()
158
151
  if (skipBackend) {
159
152
  skipped.push('backend (--no-backend)')
160
- } else if (!siteContentUuid(siteDir)) {
153
+ } else if (!siteContentUuid(siteDir, backend)) {
161
154
  skipped.push('backend (this site has never been synced)')
162
155
  } else {
163
156
  say.info("Merging the backend's content…")
164
157
  const pull = deps.pull || (await import('./pull.js')).pull
165
158
  // `--merge` rather than a plain pull: an author editing a different part of the
166
159
  // same section is not a conflict, and should not be presented as one.
167
- const passthrough = collectPassthrough(args, [
168
- '--backend',
169
- '--token'
170
- ])
171
- const res = await pull(['--merge', ...passthrough])
160
+ // Nothing of refresh's own argv is forwarded: the pull goes to the backend you are
161
+ // logged in to, as refresh does, and authenticates the same way.
162
+ const res = await pull(['--merge'])
172
163
  conflicts = res?.merge?.conflicted?.length ?? 0
173
164
  if (res?.exitCode && !conflicts) {
174
165
  // Failed for a reason other than conflicts — say so plainly rather than
@@ -192,9 +183,11 @@ export async function refresh(args = [], deps = {}) {
192
183
 
193
184
  // What is still yours to send. The point of a milestone check is knowing both
194
185
  // directions, not just that you took what was waiting.
195
- if (!skipBackend && siteContentUuid(siteDir)) {
186
+ if (!skipBackend && siteContentUuid(siteDir, backend)) {
196
187
  try {
197
- const probe = await probeUnpushed(siteDir)
188
+ // Whose asset ids the comparison reads — see status.js. Offline, so the
189
+ // origin is resolved rather than taken from a client.
190
+ const probe = await probeUnpushed(siteDir, { backend })
198
191
  if (probe.changed)
199
192
  say.dim(
200
193
  `Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`