uniweb 0.62.0 → 0.63.1

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.
@@ -17,10 +17,16 @@ import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { humanBytes } from '../utils/bytes.js'
19
19
  import { isAuthoredPreview } from '../utils/preview.js'
20
+ import {
21
+ describeRequestError,
22
+ WorkspaceMismatchError,
23
+ workspaceHandle,
24
+ workspaceHeader
25
+ } from './client.js'
20
26
  import {
21
27
  backfillEntityUuids,
22
28
  writeSiteEntityUuid,
23
- writeSiteOrg,
29
+ updateBackendState,
24
30
  emitSyncPackages,
25
31
  readZip,
26
32
  diffSiteUnits,
@@ -646,46 +652,115 @@ export function writeItemUuids(siteDir, backend, map) {
646
652
  }
647
653
 
648
654
  /**
649
- * The org this site was created under, as `@handle`, or null.
655
+ * The workspace this project's requests name on `backend`, as the header names it —
656
+ * `@handle`, or a unit's bare uuid when that unit has no handle — or null for none.
657
+ *
658
+ * Read back from `backend`'s section of `sync.json`: `site.org` holds a handle, stored
659
+ * bare and re-dressed with its `@`; `site.unit` holds a unit's uuid, for a workspace
660
+ * that has no handle. At most one is set (`writeSiteWorkspace`). ⛔ They are two keys
661
+ * because the `@` is the only thing telling the two apart — our own handle grammar
662
+ * admits a uuid-shaped handle — and a bare value at rest has lost it.
650
663
  *
651
- * Read back from `backend`'s `site.org` in `sync.json` (stored bare — see
652
- * `writeSiteOrg`) and re-dressed with the `@` the CLI and the wire both use. Callers
653
- * pass it as `--as-org`'s default so an org named once, at create, does not have to
654
- * be re-typed on every later push.
664
+ * Recorded at create — the site's owner — or adopted later from a backend's `409
665
+ * wrong_workspace`, which may name a PARENT workspace that contains the site rather
666
+ * than the site's own unit. ⇒ It is "the workspace these requests name", never
667
+ * printed as "the site's org".
655
668
  *
656
- * Deliberately NOT a fallback for the flag: an explicit `--as-org` always wins and
657
- * rides verbatim, so this can only add a value where the CLI previously sent none.
669
+ * Deliberately NOT a fallback for the flag: an explicit `--org` always wins.
658
670
  *
659
671
  * @param {string} siteDir
660
672
  * @param {string} backend - the origin whose record to read
661
673
  * @returns {string|null}
662
674
  */
663
- export function readSiteOrg(siteDir, backend) {
664
- const org = readBackendState(siteDir, backend).site?.org
665
- return typeof org === 'string' && org ? `@${org.replace(/^@/, '')}` : null
675
+ export function readSiteWorkspace(siteDir, backend) {
676
+ const site = readBackendState(siteDir, backend).site || {}
677
+ const org = typeof site.org === 'string' ? site.org.trim().replace(/^@/, '') : ''
678
+ if (org) return `@${org}`
679
+ const unit = typeof site.unit === 'string' ? site.unit.trim() : ''
680
+ return unit || null
666
681
  }
667
682
 
668
683
  /**
669
- * Record the org a just-minted site was created under, if one was named.
684
+ * Record the workspace this project's requests name on `backend` — a handle in
685
+ * `site.org` (bare), a handle-less unit in `site.unit`, clearing the other; null
686
+ * clears both (the personal workspace, named by naming none). Returns what was
687
+ * recorded, in the header's form.
670
688
  *
671
- * Only what we were TOLD is recorded — when no `--as-org` was passed the backend
672
- * chose the owner and its create response carries no org, so there is nothing to
673
- * write and guessing one would be worse than the gap. Returns the display form for
674
- * the caller's "here's what resolved" line, or null when nothing was recorded.
689
+ * @param {string} siteDir
690
+ * @param {string} backend
691
+ * @param {string|null|undefined} workspace - `@handle`, a unit's uuid, or null
692
+ * @returns {string|null}
693
+ */
694
+ function writeSiteWorkspace(siteDir, backend, workspace) {
695
+ const w = workspaceHeader(workspace)
696
+ updateBackendState(siteDir, backend, {
697
+ site: {
698
+ org: w?.startsWith('@') ? w.slice(1) : undefined,
699
+ unit: w && !w.startsWith('@') ? w : undefined
700
+ }
701
+ })
702
+ return w
703
+ }
704
+
705
+ /** A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal workspace`. */
706
+ export function describeWorkspace(workspace) {
707
+ if (!workspace) return 'your personal workspace'
708
+ return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
709
+ }
710
+
711
+ /**
712
+ * Point `client` at the workspace this project's requests name — `x-uniweb-workspace`.
713
+ *
714
+ * ⭐ THE WORKSPACE IS PER PROJECT, per backend — the one `sync.json` recorded — never
715
+ * per login: a login is one session for the whole machine (`utils/session-file.js`).
716
+ * An explicit choice (`--org`, `--personal`, the create picker) is sent as named, and
717
+ * a `409 wrong_workspace` then STOPS the command. Otherwise the client adopts the
718
+ * workspace the backend names, and this records it — as-is, since it may be a parent
719
+ * workspace, or a unit with no handle — and says so. (`client.js::request`; agreed
720
+ * with backend 2026-09-23.)
721
+ *
722
+ * @param {import('./client.js').BackendClient} client
723
+ * @param {object} p
724
+ * @param {string} p.siteDir
725
+ * @param {string|null} p.workspace - in the header's form (`readSiteWorkspace`, or
726
+ * `workspaceHandle(--org)`), or null to name none
727
+ * @param {boolean} [p.explicit=false]
728
+ * @param {(m: string) => void} [p.note]
729
+ * @returns {import('./client.js').BackendClient}
730
+ */
731
+ export function nameSiteWorkspace(client, { siteDir, workspace, explicit = false, note }) {
732
+ return client.setWorkspace(workspace, {
733
+ explicit,
734
+ onAdopted: (adopted) => {
735
+ try {
736
+ writeSiteWorkspace(siteDir, client.origin, adopted)
737
+ } catch {
738
+ // Recording is a convenience for the next command; this one still retries.
739
+ }
740
+ note?.(
741
+ adopted
742
+ ? `Requests for this site now name ${describeWorkspace(adopted)}, the workspace the backend works on it from (recorded in sync.json).`
743
+ : 'Requests for this site now name no workspace — the backend works on it from your personal one (recorded in sync.json).'
744
+ )
745
+ }
746
+ })
747
+ }
748
+
749
+ /**
750
+ * Record the workspace a just-minted site was created under.
751
+ *
752
+ * Only what we were TOLD is recorded — the create's echo, else the workspace the
753
+ * create named; a personal site has none, and guessing one would be worse than the
754
+ * gap. Returns the recorded workspace for the caller's "here's what resolved" line,
755
+ * or null when there is none.
675
756
  *
676
757
  * @param {string} siteDir
677
- * @param {string|null|undefined} asOrg - the `--as-org` value, `@handle` or bare
758
+ * @param {string|null|undefined} owner - in the header's form, or null
678
759
  * @returns {string|null}
679
760
  */
680
- function recordSiteOrg(siteDir, backend, asOrg) {
681
- const handle = String(asOrg || '')
682
- .replace(/^@/, '')
683
- .replace(/\/.*$/, '')
684
- .trim()
685
- if (!handle) return null
761
+ function recordSiteWorkspace(siteDir, backend, owner) {
686
762
  try {
687
- writeSiteOrg(siteDir, backend, handle)
688
- return `@${handle}`
763
+ return writeSiteWorkspace(siteDir, backend, owner)
689
764
  } catch {
690
765
  // The uuid is the load-bearing back-fill; losing the org note must never
691
766
  // fail a push that already succeeded on the backend.
@@ -693,43 +768,55 @@ function recordSiteOrg(siteDir, backend, asOrg) {
693
768
  }
694
769
  }
695
770
 
771
+ /**
772
+ * The `source`s of `resolveSiteOrgForCreate` that are the user's OWN choice — named on
773
+ * the command line or picked — and so explicit: a `409 wrong_workspace` for one stops
774
+ * the command rather than adopting the backend's answer (`nameSiteWorkspace`).
775
+ */
776
+ export const EXPLICIT_OWNER = new Set(['flag', 'personal', 'picked'])
777
+
696
778
  /**
697
779
  * Resolve WHICH ORG will own a site that is about to be created.
698
780
  *
699
- * The create that mints `$uuid` is the only call that reads `as_org`, and the
700
- * backend never moves ownership afterwards. There is also no CLI verb to transfer
781
+ * The create that mints the site's uuid takes its owner from the workspace the request
782
+ * names (`x-uniweb-workspace`), and the backend never moves ownership afterwards. There is also no CLI verb to transfer
701
783
  * or delete a site. So this is a **one-shot, unrepealable** decision — and until
702
784
  * this function existed the CLI made it silently, by sending nothing and letting
703
785
  * the backend fall back to the session's personal context. A developer who belongs
704
786
  * to several orgs could put a company site, and the storage it bills, somewhere
705
787
  * they never named.
706
788
  *
707
- * `register` resolves a foundation's scope in the same order — explicit, then
708
- * recorded (`package.json::uniweb.scope`), then asked (`deriveScope`) — with one
709
- * difference: non-interactive, it PICKS the one org you belong to, else your
710
- * personal org, else the primary, and says which. This refuses instead, because a
711
- * site's owner is the one-shot decision above. ⚠️ Until 2026-09-21 this said
789
+ * `register` resolves a foundation's scope in the same order — recorded (the scope
790
+ * in its name, `@acme/marketing` in main.js), else explicit (`--scope`), else asked
791
+ * (`deriveScope`) — with one difference: non-interactive, it PICKS the one org you
792
+ * belong to, else your personal org, else the primary, and says which. This refuses
793
+ * instead, because a site's owner is the one-shot decision above. ⭐ And the two are
794
+ * separate decisions: a foundation may be registered under any org its author
795
+ * belongs to, whoever owns the site (2026-09-22) — a site's `@/x` refs take the
796
+ * FOUNDATION's scope, never this one. ⚠️ Until 2026-09-21 this said
712
797
  * `register` "has refused to guess a scope", which is wrong for a non-interactive
713
798
  * run by anyone with an org.
714
799
  *
715
- * Order, and only the last step is new:
716
- * 1. `--as-org @org` — explicit, rides verbatim
717
- * 2. `--personal` — explicit "no org, I mean it" → sends NO `as_org`
800
+ * Order:
801
+ * 1. `--org @org` — explicit; the create names it as its workspace
802
+ * 2. `--personal` — explicit "no org, I mean it" → names NO workspace
718
803
  * 3. the recorded org — `sync.json`, written at this site's own create
719
804
  * 4. the site already exists on this backend (a recorded uuid) → null; ownership
720
805
  * is settled, ask nothing
721
806
  * 5. otherwise ASK (TTY) or REFUSE (non-interactive)
722
807
  *
723
- * ⛔ **`--personal` sends no `as_org`, and is NOT the same as `--as-org @<handle>`.**
808
+ * ⛔ **`--personal` names no workspace, and is NOT the same as `--org @<handle>`.**
724
809
  * The personal *org* `@jane` is an org like any other, lazily created on first use;
725
- * the session's personal context is not an org at all. Whether the backend gives
810
+ * the session's personal workspace is not an org at all. Whether the backend gives
726
811
  * them the same owning unit is **its** business and unverified here, so the
727
- * deliberate-personal spelling reproduces today's wire byte-for-byte rather than
728
- * asserting an equivalence this lane cannot check.
729
- *
730
- * @returns {Promise<{ asOrg: string|null, refused?: true, reason?: string }>}
731
- * `asOrg: null` with no `refused` means "send no as_org" — either a settled site
732
- * or a deliberate personal choice.
812
+ * deliberate-personal spelling names nothing rather than asserting an equivalence
813
+ * this lane cannot check.
814
+ *
815
+ * @returns {Promise<{ workspace: string|null, source?: string, refused?: true, reason?: string }>}
816
+ * `workspace: null` with no `refused` means "name no workspace" — either a settled site
817
+ * or a deliberate personal choice. `source` says which answer it is: `flag`,
818
+ * `personal` and `picked` are the user's own choice (explicit — a mismatch then
819
+ * stops the command); `recorded`, `existing` and `offline` are not.
733
820
  */
734
821
  export async function resolveSiteOrgForCreate({
735
822
  client,
@@ -739,21 +826,21 @@ export async function resolveSiteOrgForCreate({
739
826
  personal = false,
740
827
  offline = false
741
828
  }) {
742
- if (flag) return { asOrg: flag }
743
- if (personal) return { asOrg: null }
829
+ if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
830
+ if (personal) return { workspace: null, source: 'personal' }
744
831
 
745
- const recorded = readSiteOrg(siteDir, client?.origin)
746
- if (recorded) return { asOrg: recorded }
832
+ const recorded = readSiteWorkspace(siteDir, client?.origin)
833
+ if (recorded) return { workspace: recorded, source: 'recorded' }
747
834
 
748
835
  // Already created ⇒ nothing to decide: ownership was settled at its create and
749
836
  // re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
750
837
  // the same project may be brand new on one and long-established on another.
751
- if (readBackendState(siteDir, client?.origin).site?.uuid) return { asOrg: null }
838
+ if (readBackendState(siteDir, client?.origin).site?.uuid) return { workspace: null, source: 'existing' }
752
839
 
753
840
  // An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
754
841
  // creating nothing, so there is no decision to force. Say what is unresolved
755
842
  // instead of prompting for an answer the run will not use.
756
- if (offline) return { asOrg: null }
843
+ if (offline) return { workspace: null, source: 'offline' }
757
844
 
758
845
  // `--yes` promises "never block on a prompt", so it has to answer this one too —
759
846
  // and the only honest non-blocking answer to an unanswerable ownership question
@@ -763,13 +850,13 @@ export async function resolveSiteOrgForCreate({
763
850
  const { isNonInteractive } = await import('../utils/interactive.js')
764
851
  if (isNonInteractive(args) || args.includes('--yes')) {
765
852
  return {
766
- asOrg: null,
853
+ workspace: null,
767
854
  refused: true,
768
855
  reason:
769
856
  'This site does not exist on the backend yet, and no org was named.\n' +
770
857
  ' The create decides who OWNS it — and which workspace its storage is billed to —\n' +
771
858
  ' one time, with no CLI way to change it afterwards. Name it explicitly:\n' +
772
- ' --as-org @org create it under an organization\n' +
859
+ ' --org @org create it under an organization\n' +
773
860
  ' --personal create it under your personal account, deliberately'
774
861
  }
775
862
  }
@@ -787,7 +874,7 @@ export async function resolveSiteOrgForCreate({
787
874
  token: await client.token()
788
875
  })
789
876
  } catch (err) {
790
- return { asOrg: null, refused: true, reason: err.message }
877
+ return { workspace: null, refused: true, reason: err.message }
791
878
  }
792
879
  const personalHandle = envelope.account_handle || null
793
880
  const prompts = (await import('prompts')).default
@@ -817,9 +904,9 @@ export async function resolveSiteOrgForCreate({
817
904
  }
818
905
  }
819
906
  )
820
- if (!choice) return { asOrg: null, refused: true, reason: 'No owner chosen.' }
821
- if (choice === ':personal') return { asOrg: null }
822
- if (choice !== ':new') return { asOrg: `@${choice}` }
907
+ if (!choice) return { workspace: null, refused: true, reason: 'No owner chosen.' }
908
+ if (choice === ':personal') return { workspace: null, source: 'picked' }
909
+ if (choice !== ':new') return { workspace: `@${choice}`, source: 'picked' }
823
910
 
824
911
  const answer = await prompts(
825
912
  {
@@ -836,16 +923,16 @@ export async function resolveSiteOrgForCreate({
836
923
  }
837
924
  )
838
925
  if (!answer.handle)
839
- return { asOrg: null, refused: true, reason: 'No org handle given.' }
926
+ return { workspace: null, refused: true, reason: 'No org handle given.' }
840
927
  try {
841
928
  const org = await createOrg({
842
929
  apiBase: client.origin,
843
930
  token: await client.token(),
844
931
  handle: bareHandle(answer.handle)
845
932
  })
846
- return { asOrg: `@${org.handle}` }
933
+ return { workspace: `@${org.handle}`, source: 'picked' }
847
934
  } catch (err) {
848
- return { asOrg: null, refused: true, reason: err.message }
935
+ return { workspace: null, refused: true, reason: err.message }
849
936
  }
850
937
  }
851
938
 
@@ -926,7 +1013,6 @@ export async function ensureSiteExists({
926
1013
  siteDir,
927
1014
  name,
928
1015
  foundation,
929
- asOrg,
930
1016
  note
931
1017
  }) {
932
1018
  // One read serves both the binding check and the create's defaults, so callers
@@ -942,7 +1028,7 @@ export async function ensureSiteExists({
942
1028
  }
943
1029
  const known = readBackendState(siteDir, client.origin).site?.uuid
944
1030
  if (known) {
945
- return { uuid: known, created: false, org: readSiteOrg(siteDir, client.origin) }
1031
+ return { uuid: known, created: false, org: readSiteWorkspace(siteDir, client.origin) }
946
1032
  }
947
1033
 
948
1034
  // Both are required by the create. Catching it here turns a 400 into a sentence
@@ -963,10 +1049,10 @@ export async function ensureSiteExists({
963
1049
 
964
1050
  let res
965
1051
  try {
1052
+ // The owner is the workspace the client names — `nameSiteWorkspace` set it.
966
1053
  res = await client.createSite({
967
1054
  name: siteName,
968
- foundation: siteFoundation,
969
- asOrg
1055
+ foundation: siteFoundation
970
1056
  })
971
1057
  } catch (err) {
972
1058
  return { uuid: null, created: false, reason: err.message }
@@ -999,40 +1085,31 @@ export async function ensureSiteExists({
999
1085
  // this point cannot leave a cache pointing at a different site with no way to
1000
1086
  // detect it.
1001
1087
  updateSyncCache(siteDir, client.origin, { siteUuid: minted })
1002
- const org = await recordAndDescribeOwner({
1003
- client,
1004
- siteDir,
1005
- payload,
1006
- asOrg,
1007
- note
1008
- })
1088
+ const org = await recordAndDescribeOwner({ client, siteDir, payload, note })
1009
1089
  return { uuid: minted, created: true, org }
1010
1090
  }
1011
1091
 
1012
1092
  /**
1013
1093
  * Record and announce who owns a just-created site, from the backend's own echo.
1014
1094
  *
1015
- * The echo reports what the site **is**; `asOrg` is only what we asked for. They
1016
- * agree in the normal case and the echo is the one to trust — it is read back off
1017
- * the created entity, so it also covers the case we could never record before: no
1018
- * `--as-org` at all, where the backend picked and we had nothing true to write.
1095
+ * The echo reports what the site **is**; the workspace the create named
1096
+ * (`client.workspace`) is only what we asked for. They agree in the normal case and the
1097
+ * echo is the one to trust — it is read back off the created entity.
1019
1098
  *
1020
1099
  * ⚠️ `org: null` is MEANINGFUL (personal), not missing. An older backend omits the
1021
1100
  * key entirely, which is the only case that falls back to what we asked for.
1022
1101
  *
1023
1102
  * @returns {Promise<string|null>} the display handle recorded, or null for personal
1024
1103
  */
1025
- async function recordAndDescribeOwner({
1026
- client,
1027
- siteDir,
1028
- payload,
1029
- asOrg,
1030
- note
1031
- }) {
1104
+ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
1032
1105
  const echoed = payload && 'org' in payload ? payload.org : undefined
1033
1106
  const owner =
1034
- echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
1035
- const org = recordSiteOrg(siteDir, client.origin, owner)
1107
+ echoed === undefined
1108
+ ? client.workspace
1109
+ : typeof echoed === 'string'
1110
+ ? workspaceHandle(echoed)
1111
+ : null
1112
+ const org = recordSiteWorkspace(siteDir, client.origin, owner)
1036
1113
 
1037
1114
  // ⛔ No sync scope to record. It WAS `site.yml::$backend`, written here because this
1038
1115
  // function is the one place both create paths meet. The scope is now the KEY the
@@ -1040,7 +1117,7 @@ async function recordAndDescribeOwner({
1040
1117
  // separately would be recording the name of the drawer inside the drawer.
1041
1118
  note?.(
1042
1119
  org
1043
- ? `Created the site on ${client.origin} under ${org} — recorded in sync.json.`
1120
+ ? `Created the site on ${client.origin} under ${describeWorkspace(org)} — recorded in sync.json.`
1044
1121
  : `Created the site on ${client.origin}, owned personally — recorded in sync.json.`
1045
1122
  )
1046
1123
 
@@ -1147,21 +1224,14 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1147
1224
  // `sync.json`, which the same push wrote. Reading the live file rather than a
1148
1225
  // snapshot is what makes a moved map (a teammate's push, a pull) read as
1149
1226
  // changed instead of matching a copy of itself.
1150
- // · RECORDED — the site's own org, from `sync.json` (this backend's `site.org`).
1151
- //
1152
- // ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
1153
- // a foundation-relative `@/member` into the `@org/member` the push shipped and
1154
- // keyed its hashes by. Without it the emit does not fail — `buildRecordEntities`
1155
- // WARNS and ships the model unresolved, deliberately, so an org-less export still
1156
- // works — so every record of a `@/`-scoped collection is emitted under a key that
1157
- // can never match its banked one, and reads as changed forever.
1158
1227
  //
1159
- // ⚠️ It hides in plain sight because `@std/…` queries are unaffected: their
1160
- // scope is already absolute, so they match. A site mixing both — the marketing
1161
- // fixture has `@std/person` AND `@proximify/member` — shows some records settling
1162
- // and others never settling, which reads like a content problem rather than a
1163
- // resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
1164
- // immediately after a successful push; passing the org took it to 1.
1228
+ // ⛔ A site's `@/member` must resolve to the same `@scope/member` the push keyed its
1229
+ // hashes by, or every record of a `@/`-scoped query reads as changed forever — and
1230
+ // silently, since `@std/…` queries are absolute and settle while the others never
1231
+ // do (measured on matinee 2026-08-29: 4 changed right after a push). The scope is
1232
+ // the FOUNDATION's, which the emit reads itself, so both sides resolve alike.
1233
+ // ⚠️ Until 2026-09-23 this said the site's recorded ORG resolves it and must be
1234
+ // passed in — true before 2026-09-22, and read as a reason to pass it.
1165
1235
  const pkg = await comparisonEmit(siteDir, { backend, priorHashes, sendAll })
1166
1236
  const changed =
1167
1237
  (pkg.siteContent?.entityCount || 0) + (pkg.records?.entityCount || 0)
@@ -1180,9 +1250,10 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1180
1250
  * · RE-DERIVED asset identity, from this backend's COMMITTED asset map in
1181
1251
  * `sync.json`, so a moved map reads as changed rather than matching
1182
1252
  * a copy of itself.
1183
- * · RECORDED the site's org, which resolves a foundation-relative `@/x` into
1184
- * the `@org/x` the push keyed its hashes by, and the collection
1185
- * identity a push stamps, so the document is the one a push builds.
1253
+ * · RECORDED the collection identity a push stamps, so the document is the
1254
+ * one a push builds. (A site's `@/x` resolves into its foundation's
1255
+ * scope, which the emit reads itself — not the site's org, which this
1256
+ * bullet named until 2026-09-23.)
1186
1257
  * ⚠️ Identity is parity, not correctness: `entityContentHash` strips
1187
1258
  * `$`-sigils, so a query's `$uuid` never moves a hash (measured
1188
1259
  * 2026-09-21). This read no backend from 2026-09-20 to 2026-09-21 —
@@ -1201,7 +1272,6 @@ async function comparisonEmit(
1201
1272
  // comparison against the wrong one reports every media ref as changed. Absent is
1202
1273
  // honest rather than a default — no backend, no known ids.
1203
1274
  const assetIds = backend ? readBackendState(siteDir, backend).assets || {} : {}
1204
- const org = readSiteOrg(siteDir, backend)
1205
1275
  const queryUuids = readQueryUuids(siteDir, backend)
1206
1276
  return emitSyncPackages(siteDir, {
1207
1277
  backend,
@@ -1210,8 +1280,9 @@ async function comparisonEmit(
1210
1280
  sendAll,
1211
1281
  ...applied,
1212
1282
  ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
1213
- ...(Object.keys(assetIds).length ? { assetIds } : {}),
1214
- ...(org ? { org } : {})
1283
+ ...(Object.keys(assetIds).length ? { assetIds } : {})
1284
+ // ⛔ No `org`: a site's `@/x` refs resolve into its FOUNDATION's scope, which the
1285
+ // emit reads itself — the same read a push makes, so the hashes compare alike.
1215
1286
  })
1216
1287
  }
1217
1288
 
@@ -1242,6 +1313,37 @@ export async function rebankSyncHashes(siteDir, backend = null) {
1242
1313
  return Object.keys(pkg.hashes || {}).length
1243
1314
  }
1244
1315
 
1316
+ /**
1317
+ * What a push did with the site's `template:` intent — read from the backend's own
1318
+ * reply, never inferred from what we sent.
1319
+ *
1320
+ * The field is `template`, carrying `designated` or `undesignated`, and it is ABSENT
1321
+ * when the designation did not move (backend, collab framework↔backend #6, 2026-09-23;
1322
+ * kb/framework/reference/template-designation-on-push.md E6).
1323
+ *
1324
+ * ⛔ TWO STATES, NOT THREE — and silence means "unchanged", never "failed". A push that
1325
+ * asks to designate where the backend will not is REFUSED WHOLE, and that refusal
1326
+ * carries its own message (§6), so there is no asked-but-skipped state to print here.
1327
+ *
1328
+ * ⚠️ Backend named the field, not its nesting, so the top level and `report` are both
1329
+ * read; and an unrecognized value is printed verbatim rather than dropped, because a
1330
+ * state we do not know about rendering as SILENCE is the exact failure this line exists
1331
+ * to prevent.
1332
+ *
1333
+ * @param {object|null} payload - the parsed push/create response
1334
+ * @returns {string|null} the state as the backend spelled it
1335
+ */
1336
+ export function templateOutcome(payload) {
1337
+ const v = payload?.template ?? payload?.report?.template
1338
+ return typeof v === 'string' && v.trim() ? v.trim() : null
1339
+ }
1340
+
1341
+ /** The sentence for each state the backend can report. */
1342
+ const TEMPLATE_SAID = {
1343
+ designated: 'The backend designated this site as a template.',
1344
+ undesignated: 'The backend undesignated this site — it is no longer a template.'
1345
+ }
1346
+
1245
1347
  /**
1246
1348
  * Submit a site's emitted sync packages over both directional lanes, back-fill the
1247
1349
  * minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
@@ -1252,7 +1354,6 @@ export async function rebankSyncHashes(siteDir, backend = null) {
1252
1354
  * @param {string} params.siteDir - the site root (for $uuid write-back + the cache)
1253
1355
  * @param {object} params.pkg - the `emitSyncPackages` result
1254
1356
  * ({ siteContent, collections, siteContentUuid, hashes })
1255
- * @param {string|null} [params.asOrg] - act-as org (membership-gated), forwarded to each lane
1256
1357
  * @param {{info,note,error,dim?:Function}} params.report - injected logging
1257
1358
  * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
1258
1359
  * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
@@ -1261,7 +1362,6 @@ export async function pushSyncPackages({
1261
1362
  client,
1262
1363
  siteDir,
1263
1364
  pkg,
1264
- asOrg,
1265
1365
  report
1266
1366
  }) {
1267
1367
  const { siteContent, records, siteContentUuid, hashes, applied } = pkg
@@ -1275,9 +1375,9 @@ export async function pushSyncPackages({
1275
1375
 
1276
1376
  // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1277
1377
  // returning the client's Response promise (so the "Pushing …" line prints before the
1278
- // request fires). The client carries `collision=force` (last-push-wins) + the optional
1279
- // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
1280
- // (already reported).
1378
+ // request fires). The client carries `collision=force` (last-push-wins) and names the
1379
+ // site's workspace. Returns the parsed payload, or null on any transport/HTTP/parse
1380
+ // failure (already reported).
1281
1381
  // `boundUuid` is set only for the lanes that address an EXISTING site by uuid
1282
1382
  // (content UPDATE, folder push) — never for the CREATE, which has no uuid to be
1283
1383
  // wrong about. It is what lets a 404 be read as "the site this clone is bound to
@@ -1286,15 +1386,16 @@ export async function pushSyncPackages({
1286
1386
  label,
1287
1387
  doRequest,
1288
1388
  explainStale = async () => [],
1289
- { boundUuid = null } = {}
1389
+ { boundUuid = null, reportTemplate = false } = {}
1290
1390
  ) => {
1291
1391
  info(`Pushing ${label} to ${dim(client.origin)} …`)
1292
1392
  let res
1293
1393
  try {
1294
1394
  res = await doRequest()
1295
1395
  } catch (err) {
1296
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
1297
- note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1396
+ error(describeRequestError(err, client.origin))
1397
+ if (!(err instanceof WorkspaceMismatchError))
1398
+ note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1298
1399
  return null
1299
1400
  }
1300
1401
  if (!res.ok) {
@@ -1483,12 +1584,25 @@ export async function pushSyncPackages({
1483
1584
  if (body) note(body.slice(0, 800))
1484
1585
  return null
1485
1586
  }
1587
+ let payload
1486
1588
  try {
1487
- return await res.json()
1589
+ payload = await res.json()
1488
1590
  } catch (err) {
1489
1591
  error(`Could not parse the ${label} response as JSON: ${err.message}`)
1490
1592
  return null
1491
1593
  }
1594
+ // The designation outcome rides on the site-content lanes only — the one place
1595
+ // the author's `template:` intent was sent.
1596
+ if (reportTemplate) {
1597
+ const state = templateOutcome(payload)
1598
+ if (state) {
1599
+ note(
1600
+ TEMPLATE_SAID[state] ||
1601
+ `The backend reported the template state as "${state}".`
1602
+ )
1603
+ }
1604
+ }
1605
+ return payload
1492
1606
  }
1493
1607
 
1494
1608
  // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
@@ -1560,9 +1674,7 @@ export async function pushSyncPackages({
1560
1674
  const finalized = await pushLane(
1561
1675
  'site-content',
1562
1676
  () =>
1563
- client.updateSiteContent(siteContentUuid, siteContent.buffer, {
1564
- asOrg
1565
- }),
1677
+ client.updateSiteContent(siteContentUuid, siteContent.buffer),
1566
1678
  () =>
1567
1679
  explainStaleSiteContent({
1568
1680
  client,
@@ -1570,7 +1682,7 @@ export async function pushSyncPackages({
1570
1682
  localBuffer: siteContent.buffer,
1571
1683
  uuid: siteContentUuid
1572
1684
  }),
1573
- { boundUuid: siteContentUuid }
1685
+ { boundUuid: siteContentUuid, reportTemplate: true }
1574
1686
  )
1575
1687
  if (!finalized) {
1576
1688
  mergeHarvested()
@@ -1591,8 +1703,11 @@ export async function pushSyncPackages({
1591
1703
  }
1592
1704
  finalizedTotal += finalized.length
1593
1705
  } else {
1594
- const payload = await postLane('site-content', () =>
1595
- client.createSiteContent(siteContent.buffer, { asOrg })
1706
+ const payload = await postLane(
1707
+ 'site-content',
1708
+ () => client.createSiteContent(siteContent.buffer),
1709
+ undefined,
1710
+ { reportTemplate: true }
1596
1711
  )
1597
1712
  if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
1598
1713
  const minted = extractMintedSiteUuid(payload)
@@ -1613,13 +1728,7 @@ export async function pushSyncPackages({
1613
1728
  // owner present or absent depending on whether the site happens to have images.
1614
1729
  // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
1615
1730
  // and `site` (NOT beside `finalized`, which lives at report.finalized).
1616
- const createdOrg = await recordAndDescribeOwner({
1617
- client,
1618
- siteDir,
1619
- payload,
1620
- asOrg,
1621
- note
1622
- })
1731
+ const createdOrg = await recordAndDescribeOwner({ client, siteDir, payload, note })
1623
1732
  if (createdOrg)
1624
1733
  wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1625
1734
  const createdFinalized = extractFinalized(payload)
@@ -1642,7 +1751,7 @@ export async function pushSyncPackages({
1642
1751
  }
1643
1752
  const finalized = await pushLane(
1644
1753
  'records',
1645
- () => client.pushFolder(boundSiteUuid, records.buffer, { asOrg }),
1754
+ () => client.pushFolder(boundSiteUuid, records.buffer),
1646
1755
  undefined,
1647
1756
  { boundUuid: boundSiteUuid }
1648
1757
  )