uniweb 0.59.2 → 0.60.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.
@@ -20,8 +20,9 @@
20
20
  * Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
21
21
  * (foundation code → catalog). For a self-contained artifact, see `uniweb export`.
22
22
  *
23
- * Backend: BackendClient. Origin from --backend > UNIWEB_REGISTER_URL
24
- * > default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
23
+ * Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
24
+ * (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
25
+ * No `--backend` or `--token`: switching and signing in are `uniweb login`.
25
26
  *
26
27
  * Usage:
27
28
  * uniweb publish Bring the foundation along, sync, and go live
@@ -37,9 +38,7 @@
37
38
  * uniweb publish --personal Own the new site personally, deliberately. Sends
38
39
  * NO `as_org` — byte-identical to the wire before
39
40
  * the owner prompt existed. First publish only.
40
- * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
41
- * uniweb publish --backend <url> Override the backend origin
42
- * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
41
+ * uniweb publish --no-save Do not record this publish in deploy.yml
43
42
  */
44
43
 
45
44
  import { existsSync, readFileSync } from 'node:fs'
@@ -50,7 +49,7 @@ import yaml from 'js-yaml'
50
49
 
51
50
  import {
52
51
  loadDeployYml,
53
- resolveTarget,
52
+ resolvePublishTarget,
54
53
  recordLastDeploy,
55
54
  collectSchemalessData,
56
55
  collectSchemalessDataAssets,
@@ -67,16 +66,18 @@ import { isSiteRelativeExtensionUrl } from '@uniweb/build'
67
66
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
68
67
 
69
68
  import { BackendClient } from '../backend/client.js'
70
- import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
69
+ import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
70
+ import { resolveSiteDir } from './deploy.js'
71
71
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
72
72
  import { readFlagValue, readOrgFlag } from '../utils/args.js'
73
73
  import { checkFlags } from '../utils/flag-guard.js'
74
74
  import {
75
- assertSiteBackendScope,
76
- readSiteIdentity
75
+ syncedElsewhere,
76
+ describeSyncedElsewhere
77
77
  } from '../utils/site-identity.js'
78
78
  import { isNonInteractive, confirm } from '../utils/interactive.js'
79
79
  import { guardEmptyRecords } from '../utils/records-guard.js'
80
+ import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
80
81
  import { headProvenance } from '../utils/git.js'
81
82
  import {
82
83
  makeModelResolver,
@@ -92,7 +93,12 @@ import {
92
93
  resolveSiteOrgForCreate
93
94
  } from '../backend/site-sync.js'
94
95
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
95
- import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
96
+ import {
97
+ updateBackendMap,
98
+ readBackendState,
99
+ carryServed,
100
+ SYNC_STORE_FILE
101
+ } from '@uniweb/build/uwx'
96
102
  import {
97
103
  bringFoundationAlong,
98
104
  bringExtensionsAlong
@@ -158,7 +164,7 @@ function languagesFromSiteYml(siteYml) {
158
164
  return [def, ...norm.filter((l) => l !== def)]
159
165
  }
160
166
 
161
- // Persist deploy.yml lastDeploy memory (skipped on --no-save / autoSave 'off').
167
+ // Persist the deploys memo in deploy.yml (skipped on --no-save / saveDeploys: false).
162
168
  /**
163
169
  * A one-line, human-readable account of a service request, for a terminal.
164
170
  *
@@ -223,7 +229,7 @@ function describeServices(rows) {
223
229
  }
224
230
 
225
231
  async function persistLastDeploy(siteDir, opts) {
226
- if (opts.autoSave === 'off') return
232
+ if (opts.saveDeploys === false) return
227
233
  try {
228
234
  const result = await recordLastDeploy(siteDir, opts)
229
235
  if (result?.created)
@@ -253,33 +259,48 @@ export async function publish(args = []) {
253
259
  // is not a gate.
254
260
  await warnIfContentDoesNotConform(siteDir, { args })
255
261
  const siteYml = readSiteYml(join(siteDir, 'site.yml'))
256
- // The site's deploy.yml-bound backend (where it was published) feeds the
257
- // resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
258
- // The project's own statement of where its identity lives. Feeds the origin ladder
259
- // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
260
- // targets the backend it is bound to instead of whatever they last logged into.
261
- // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
262
- const siteScope = readSiteIdentity(siteDir).backend
263
- const siteBackend = await resolveSiteBackend(siteDir)
262
+ // ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
263
+ // backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
264
+ // override — outranks it, and nothing talks to a backend the user is not logged in to:
265
+ // logged in nowhere, the origin is the default backend and the first request asks for
266
+ // that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
267
+ // ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
268
+ // NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
269
+ // be reached: *"We do not allow any communication with backend if the user is not
270
+ // logged into a backend."*
264
271
 
265
272
  const client = new BackendClient({
266
- originFlag:
267
- readFlagValue(args, '--backend'),
268
- siteScope,
269
- siteBackend,
270
- token: readFlagValue(args, '--token') || undefined,
271
273
  args,
272
274
  command: 'Publishing'
273
275
  })
274
276
 
275
- // ⛔ SCOPE CHECK — before the foundation bring-along, the sync, or the go-live. A
276
- // publish is the longest of these flows and the most expensive to unwind, so it is
277
- // the one that most benefits from failing at the first step rather than at the fifth.
278
- const scope = assertSiteBackendScope(siteDir, client.origin)
279
- if (!scope.ok) {
280
- say.err(scope.message)
281
- scope.hint.forEach(say.dim)
282
- return { exitCode: 1 }
277
+ // ⛔ A COPY OF ANOTHER PROJECT — before the foundation bring-along, the sync, or the
278
+ // go-live. A plain directory copy carries sync.json, so it holds the ORIGINAL's site
279
+ // on this backend and would publish over it (utils/site-copies.js). A publish is the
280
+ // longest of these flows and the most expensive to unwind, so it is the one that
281
+ // most benefits from failing at the first step rather than at the fifth.
282
+ // *(The backend scope check that stood here went on 2026-09-20 — see push.js.)*
283
+ {
284
+ const copies = findSiteCopies(siteDir, client.origin)
285
+ if (copies.length) {
286
+ const said = describeSiteCopies(copies, client.origin, 'publish')
287
+ say.err(said.headline)
288
+ for (const line of said.lines) say.dim(line)
289
+ return { exitCode: 1 }
290
+ }
291
+ }
292
+
293
+ // ⚠️ Logged in to a backend where this project has no site, while it has one
294
+ // elsewhere: following the login CREATES a second site. Say so before the owner
295
+ // question, which otherwise arrives with no reason attached. Not when
296
+ // UNIWEB_REGISTER_URL chose the backend — that is already a decision.
297
+ if (!process.env.UNIWEB_REGISTER_URL) {
298
+ const known = syncedElsewhere(siteDir, client.origin)
299
+ if (known) {
300
+ const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'publish')
301
+ say.info(headline)
302
+ for (const line of rest) say.dim(line)
303
+ }
283
304
  }
284
305
 
285
306
  // WHO will own this site, if this publish is the one that creates it. Resolved
@@ -341,35 +362,36 @@ export async function publish(args = []) {
341
362
  // nothing scaffolded ever set it, it is in no public doc, and the backend
342
363
  // may already have been ignoring it.
343
364
 
344
- // deploy.yml target (the Uniweb hosting memory). No --target on publish — it
345
- // always targets Uniweb hosting; resolveTarget gives us the target name +
346
- // autoSave for the lastDeploy memo.
365
+ // deploy.yml target (the Uniweb hosting memory) — the one for the backend this
366
+ // publish goes to. ⭐ The target follows the backend, never the reverse: the default
367
+ // target only breaks a tie (resolvePublishTarget). With no deploy.yml, or no target
368
+ // naming this backend, persistLastDeploy adds one.
369
+ //
370
+ // ⛔ This took deploy.yml's DEFAULT target until 2026-09-21, whatever backend it was
371
+ // publishing to — a publish to a second backend wrote over the default target's
372
+ // record and compared its request against that target's fingerprint.
347
373
  let resolved
348
374
  let priorRequest = null
349
375
  try {
350
376
  const deployYml = await loadDeployYml(siteDir)
351
- // No --target on publish — it always targets Uniweb hosting; resolveTarget
352
- // returns the uniweb default (fromFile:false) when there's no deploy.yml, so
353
- // persistLastDeploy scaffolds the file as the "where it's deployed" record.
354
- resolved = resolveTarget(deployYml, null)
355
- // The last request we are known to have sent, for the declaration gate below.
356
- // Read from the SAME deploy.yml load — one read, and the memo is the only
357
- // durable record of it (see backend/service-request.js for why not the cache).
358
- priorRequest =
359
- deployYml?.lastDeploy?.[resolved?.targetName] ||
360
- deployYml?.lastDeploy?.uniweb ||
361
- null
377
+ resolved = resolvePublishTarget(deployYml, client.origin, {
378
+ defaultBackend: DEFAULT_BACKEND_ORIGIN
379
+ })
380
+ // The last request we are known to have sent TO THIS BACKEND, for the declaration
381
+ // gate below. Read from the SAME deploy.yml load — one read, and the memo is the
382
+ // only durable record of it (see backend/service-request.js for why not the cache).
383
+ priorRequest = deployYml?.deploys?.[resolved.targetName] || null
362
384
  } catch {
363
385
  // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
364
386
  resolved = {
365
387
  targetName: 'production',
366
388
  host: 'uniweb',
367
389
  config: {},
368
- autoSave: 'lastDeploy',
390
+ saveDeploys: true,
369
391
  fromFile: false
370
392
  }
371
393
  }
372
- const autoSave = noSave ? 'off' : resolved.autoSave || 'lastDeploy'
394
+ const saveDeploys = !noSave && resolved.saveDeploys !== false
373
395
 
374
396
  // A SITE-RELATIVE extension URL cannot work on Uniweb hosting: the published
375
397
  // site ships no JS, so nothing serves that path. The request falls through to
@@ -410,7 +432,7 @@ export async function publish(args = []) {
410
432
  say.info('Dry run — would bring the foundation along, sync, and go live:')
411
433
  say.dim(`Backend : ${client.origin}`)
412
434
  say.dim(
413
- `site_uuid : ${siteYml.$uuid || '(none — the site is created before anything uploads)'}`
435
+ `site_uuid : ${readBackendState(siteDir, client.origin).site?.uuid || '(none — the site is created before anything uploads)'}`
414
436
  )
415
437
  const langs = languagesFromSiteYml(siteYml)
416
438
  if (langs) say.dim(`Languages : ${langs.join(', ')}`)
@@ -531,8 +553,9 @@ export async function publish(args = []) {
531
553
  let probe
532
554
  try {
533
555
  probe = await emitSyncPackages(siteDir, {
556
+ backend: client.origin,
534
557
  // Placement identity for the folder — see writeFolderItemUuids.
535
- folderItemUuids: readFolderItemUuids(siteDir),
558
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
536
559
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
537
560
  ...(asOrg ? { org: asOrg } : {}),
538
561
  ...(foundationDir ? { foundationDir } : {}),
@@ -553,13 +576,13 @@ export async function publish(args = []) {
553
576
  // that describes one is stale — including after the documented "clear
554
577
  // `$uuid` to re-publish as a new site" recovery. Must run BEFORE the create,
555
578
  // which mints a uuid and would make the clone look bound.
556
- const droppedState = clearRemoteSyncStateIfUnbound(siteDir)
579
+ const droppedState = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
557
580
  if (droppedState.length) {
558
581
  say.dim(
559
582
  `Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
560
583
  )
561
584
  }
562
- const droppedValues = dropSiteBoundValues(siteDir)
585
+ const droppedValues = dropSiteBoundValues(siteDir, client.origin)
563
586
  if (droppedValues.length) {
564
587
  say.dim(
565
588
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
@@ -628,10 +651,10 @@ export async function publish(args = []) {
628
651
  if (Object.keys(ids).length) assetIds = ids
629
652
  // Identity into the COMMITTED map — see backend/asset-map.js. Merge, not
630
653
  // replace: this publish carries only the refs its content touched.
631
- const rec = updateAssetMap(siteDir, ids)
654
+ const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
632
655
  if (rec.written) {
633
656
  say.dim(
634
- `${ASSET_MAP_FILE} : ${rec.added.length} added, ${rec.changed.length} changed — commit it`
657
+ `${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
635
658
  )
636
659
  }
637
660
  if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
@@ -731,13 +754,13 @@ export async function publish(args = []) {
731
754
  // the pinned foundation ref and rewriting local media refs to serve URLs.
732
755
  // (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
733
756
  // collection data now lands at its serving tail, so nothing records it.)
734
- const priorHashes = readSyncCache(siteDir)
757
+ const priorHashes = readSyncCache(siteDir, client.origin)
735
758
  // publish rides the same gated push as `uniweb push`: if an app author has
736
759
  // edited since this clone last synced, the push is refused rather than
737
760
  // overwriting them, and nothing goes live. `--force` drops the precondition.
738
761
  const baseVersions = args.includes('--force')
739
762
  ? null
740
- : readBaseVersions(siteDir)
763
+ : readBaseVersions(siteDir, client.origin)
741
764
  // Per-item identity, recovered from the backend when this clone has never seen it.
742
765
  // Without it the backend re-mints every page and section row (see readItemUuids).
743
766
  const itemUuids = await ensureItemUuids({
@@ -789,15 +812,20 @@ export async function publish(args = []) {
789
812
  // network blip, a site never pushed. That is the shipped behaviour and it is safe:
790
813
  // it withholds an unchanged block and sends a changed one; it merely cannot see
791
814
  // the app's side.
792
- let declaration = decideDeclaration(siteYml, priorRequest)
815
+ // What this site is PROVISIONED with on the backend being published to.
816
+ const provisioned = readBackendState(siteDir, client.origin)
817
+ const boundUuid = provisioned.site?.uuid || null
818
+ let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
793
819
  let adopted = null
794
820
  // Before the push, so a never-synced site has no uuid and simply skips this.
795
821
  const status =
796
- typeof siteYml.$uuid === 'string' && siteYml.$uuid
797
- ? await client.siteStatus(siteYml.$uuid)
822
+ // This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
823
+ // the remote reconcile below never ran and every publish declared blind.
824
+ boundUuid
825
+ ? await client.siteStatus(boundUuid)
798
826
  : null
799
827
  if (status && Array.isArray(status.services)) {
800
- const r = reconcileRequest(siteYml, status.services, priorRequest)
828
+ const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
801
829
  if (r.action === 'none') {
802
830
  declaration = { declare: false, reason: 'in-sync' }
803
831
  } else if (r.action === 'send') {
@@ -835,7 +863,7 @@ export async function publish(args = []) {
835
863
  // for one commit. Asking is the only thing that resolves it.
836
864
  if (declaration.reason === 'conflict') {
837
865
  say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
838
- say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
866
+ say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
839
867
  say.dim(` on your site: ${describeServices(adopted)}`)
840
868
  if (isNonInteractive(args)) {
841
869
  say.dim(' Left your site as it is — run without --non-interactive to choose.')
@@ -886,7 +914,7 @@ export async function publish(args = []) {
886
914
  // site refused, where nothing of theirs changed and ours simply did not take.
887
915
  // We cannot tell those apart here, so the wording claims neither.
888
916
  say.info('Your site has different services than site.yml lists.')
889
- say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
917
+ say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
890
918
  say.dim(` on your site: ${describeServices(adopted)}`)
891
919
  // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
892
920
  // silently rewrites an authored file is the surprise this seam exists to
@@ -911,9 +939,10 @@ export async function publish(args = []) {
911
939
  let pkg
912
940
  try {
913
941
  pkg = await emitSyncPackages(siteDir, {
942
+ backend: client.origin,
914
943
  ...(declaration.declare ? {} : { declareServices: false }),
915
944
  // Placement identity for the folder — see writeFolderItemUuids.
916
- folderItemUuids: readFolderItemUuids(siteDir),
945
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
917
946
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
918
947
  ...(asOrg ? { org: asOrg } : {}),
919
948
  ...(foundationDir ? { foundationDir } : {}),
@@ -921,7 +950,7 @@ export async function publish(args = []) {
921
950
  priorHashes,
922
951
  itemUuids,
923
952
  ...(baseVersions
924
- ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
953
+ ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir, client.origin) }
925
954
  : {}),
926
955
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
927
956
  ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
@@ -988,7 +1017,7 @@ export async function publish(args = []) {
988
1017
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
989
1018
  if (pubRes.status === 401 || pubRes.status === 403) {
990
1019
  say.dim(
991
- "Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
1020
+ "Credentials weren't accepted — run `uniweb login` again."
992
1021
  )
993
1022
  }
994
1023
  if (body) say.dim(body.slice(0, 800))
@@ -1008,8 +1037,8 @@ export async function publish(args = []) {
1008
1037
  const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
1009
1038
 
1010
1039
  // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
1011
- // reuses the resolved target without re-asking). One identity:
1012
- // site.yml::$uuid. `released` records whether this publish shipped a new
1040
+ // reuses the resolved target without re-asking). Identity is sync.json's,
1041
+ // per backend. `released` records whether this publish shipped a new
1013
1042
  // foundation version (the bring-along, §4).
1014
1043
  // Record the ref that actually went live: the pinned `@scope/name@version`
1015
1044
  // from the bring-along when present, else the site.yml ref verbatim.
@@ -1021,13 +1050,13 @@ export async function publish(args = []) {
1021
1050
  const recordedRef = fnd.ref || siteYmlRef
1022
1051
  await persistLastDeploy(siteDir, {
1023
1052
  targetName: resolved.targetName,
1024
- // First publish scaffolds deploy.yml with the backend recorded on the
1025
- // target, binding the site to where it went live (uniweb.app, or a B2B
1026
- // backend). resolveSiteBackend reads it back on later publishes.
1053
+ // A target this file does not have yet — the first publish anywhere (the whole
1054
+ // file is scaffolded), or the first to a backend no target names (the target is
1055
+ // added beside the others) — records the backend it went live on.
1027
1056
  targetConfig: resolved.fromFile
1028
1057
  ? null
1029
1058
  : { host: 'uniweb', backend: client.origin },
1030
- autoSave,
1059
+ saveDeploys,
1031
1060
  lastDeploy: {
1032
1061
  at: new Date().toISOString(),
1033
1062
  host: 'uniweb',
@@ -1037,7 +1066,7 @@ export async function publish(args = []) {
1037
1066
  // service's `config` is opaque, so recording either verbatim would write
1038
1067
  // them into git. Absent when the file declares no block.
1039
1068
  ...(declaration.declare
1040
- ? fingerprintRequest(siteYml)
1069
+ ? fingerprintRequest(siteYml, provisioned)
1041
1070
  : {
1042
1071
  // Nothing was sent, so the base is unchanged — carry it forward
1043
1072
  // rather than dropping it, or the next publish would read "no record"
@@ -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
  })
@@ -841,6 +851,8 @@ export async function pull(args = [], deps = {}) {
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
  })
@@ -905,7 +917,7 @@ export async function pull(args = [], deps = {}) {
905
917
  // wrong content, and must not fail a pull whose files are already written.
906
918
  if (!dryRun) {
907
919
  try {
908
- await rebankSyncHashes(siteDir)
920
+ await rebankSyncHashes(siteDir, client.origin)
909
921
  } catch (err) {
910
922
  note(`! could not re-bank the sync cache: ${err.message}`)
911
923
  note(' The next push will re-send content that is already current.')