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.
- package/package.json +6 -6
- package/partials/agents.md +4 -3
- package/src/backend/client.js +231 -20
- package/src/backend/foundation-bring-along.js +12 -13
- package/src/backend/site-sync.js +235 -126
- package/src/commands/clone.js +35 -5
- package/src/commands/publish.js +33 -20
- package/src/commands/pull.js +55 -10
- package/src/commands/push.js +23 -17
- package/src/commands/register.js +136 -26
- package/src/commands/status.js +26 -5
- package/src/framework-index.json +6 -6
- package/src/index.js +14 -8
- package/src/utils/args.js +6 -14
- package/src/utils/flag-guard.js +17 -9
- package/src/utils/registry-orgs.js +3 -2
- package/src/utils/site-data-upload.js +13 -2
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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 `--
|
|
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
|
|
664
|
-
const
|
|
665
|
-
|
|
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
|
|
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
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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}
|
|
758
|
+
* @param {string|null|undefined} owner - in the header's form, or null
|
|
678
759
|
* @returns {string|null}
|
|
679
760
|
*/
|
|
680
|
-
function
|
|
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
|
-
|
|
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
|
|
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 —
|
|
708
|
-
*
|
|
709
|
-
* difference: non-interactive, it PICKS the one org you
|
|
710
|
-
* personal org, else the primary, and says which. This refuses
|
|
711
|
-
* site's owner is the one-shot decision above.
|
|
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
|
|
716
|
-
* 1. `--
|
|
717
|
-
* 2. `--personal` — explicit "no org, I mean it" →
|
|
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`
|
|
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
|
|
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
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
* @returns {Promise<{
|
|
731
|
-
* `
|
|
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 {
|
|
743
|
-
if (personal) return {
|
|
829
|
+
if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
|
|
830
|
+
if (personal) return { workspace: null, source: 'personal' }
|
|
744
831
|
|
|
745
|
-
const recorded =
|
|
746
|
-
if (recorded) return {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
' --
|
|
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 {
|
|
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 {
|
|
821
|
-
if (choice === ':personal') return {
|
|
822
|
-
if (choice !== ':new') return {
|
|
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 {
|
|
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 {
|
|
933
|
+
return { workspace: `@${org.handle}`, source: 'picked' }
|
|
847
934
|
} catch (err) {
|
|
848
|
-
return {
|
|
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:
|
|
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**;
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
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
|
|
1035
|
-
|
|
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
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
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
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
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
|
-
|
|
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)
|
|
1279
|
-
//
|
|
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(
|
|
1297
|
-
|
|
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
|
-
|
|
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(
|
|
1595
|
-
|
|
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
|
|
1754
|
+
() => client.pushFolder(boundSiteUuid, records.buffer),
|
|
1646
1755
|
undefined,
|
|
1647
1756
|
{ boundUuid: boundSiteUuid }
|
|
1648
1757
|
)
|