uniweb 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +17 -15
- package/src/backend/client.js +231 -20
- package/src/backend/foundation-bring-along.js +71 -65
- package/src/backend/site-sync.js +255 -127
- package/src/commands/add.js +62 -4
- package/src/commands/clone.js +36 -6
- package/src/commands/publish.js +33 -20
- package/src/commands/pull.js +68 -15
- package/src/commands/push.js +23 -17
- package/src/commands/register.js +234 -37
- package/src/commands/rename.js +12 -9
- package/src/commands/status.js +32 -7
- package/src/framework-index.json +8 -8
- package/src/index.js +43 -10
- package/src/utils/args.js +6 -14
- package/src/utils/flag-guard.js +17 -9
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +4 -1
- package/src/utils/registry-orgs.js +3 -2
- package/src/utils/scaffold.js +7 -5
- package/src/utils/site-data-upload.js +13 -2
- package/starter/foundation/main.js +2 -1
- package/templates/foundation/main.js.hbs +9 -0
- package/templates/site/site.yml.hbs +3 -2
package/src/backend/site-sync.js
CHANGED
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
15
|
-
import { join, dirname } from 'node:path'
|
|
15
|
+
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
16
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
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
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.
|
|
688
|
+
*
|
|
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
|
+
})
|
|
666
747
|
}
|
|
667
748
|
|
|
668
749
|
/**
|
|
669
|
-
* Record the
|
|
750
|
+
* Record the workspace a just-minted site was created under.
|
|
670
751
|
*
|
|
671
|
-
* Only what we were TOLD is recorded —
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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
|
|
@@ -1270,12 +1370,14 @@ export async function pushSyncPackages({
|
|
|
1270
1370
|
|
|
1271
1371
|
const wrote = []
|
|
1272
1372
|
let finalizedTotal = 0
|
|
1373
|
+
// Records sent as drafts that the backend stored enabled — reported at the end.
|
|
1374
|
+
let draftsNotKept = []
|
|
1273
1375
|
|
|
1274
1376
|
// POST one lane via the client and parse the JSON response. `doRequest` is a thunk
|
|
1275
1377
|
// returning the client's Response promise (so the "Pushing …" line prints before the
|
|
1276
|
-
// request fires). The client carries `collision=force` (last-push-wins)
|
|
1277
|
-
//
|
|
1278
|
-
// (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).
|
|
1279
1381
|
// `boundUuid` is set only for the lanes that address an EXISTING site by uuid
|
|
1280
1382
|
// (content UPDATE, folder push) — never for the CREATE, which has no uuid to be
|
|
1281
1383
|
// wrong about. It is what lets a 404 be read as "the site this clone is bound to
|
|
@@ -1284,15 +1386,16 @@ export async function pushSyncPackages({
|
|
|
1284
1386
|
label,
|
|
1285
1387
|
doRequest,
|
|
1286
1388
|
explainStale = async () => [],
|
|
1287
|
-
{ boundUuid = null } = {}
|
|
1389
|
+
{ boundUuid = null, reportTemplate = false } = {}
|
|
1288
1390
|
) => {
|
|
1289
1391
|
info(`Pushing ${label} to ${dim(client.origin)} …`)
|
|
1290
1392
|
let res
|
|
1291
1393
|
try {
|
|
1292
1394
|
res = await doRequest()
|
|
1293
1395
|
} catch (err) {
|
|
1294
|
-
error(
|
|
1295
|
-
|
|
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>')
|
|
1296
1399
|
return null
|
|
1297
1400
|
}
|
|
1298
1401
|
if (!res.ok) {
|
|
@@ -1481,12 +1584,25 @@ export async function pushSyncPackages({
|
|
|
1481
1584
|
if (body) note(body.slice(0, 800))
|
|
1482
1585
|
return null
|
|
1483
1586
|
}
|
|
1587
|
+
let payload
|
|
1484
1588
|
try {
|
|
1485
|
-
|
|
1589
|
+
payload = await res.json()
|
|
1486
1590
|
} catch (err) {
|
|
1487
1591
|
error(`Could not parse the ${label} response as JSON: ${err.message}`)
|
|
1488
1592
|
return null
|
|
1489
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
|
|
1490
1606
|
}
|
|
1491
1607
|
|
|
1492
1608
|
// POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
|
|
@@ -1558,9 +1674,7 @@ export async function pushSyncPackages({
|
|
|
1558
1674
|
const finalized = await pushLane(
|
|
1559
1675
|
'site-content',
|
|
1560
1676
|
() =>
|
|
1561
|
-
client.updateSiteContent(siteContentUuid, siteContent.buffer,
|
|
1562
|
-
asOrg
|
|
1563
|
-
}),
|
|
1677
|
+
client.updateSiteContent(siteContentUuid, siteContent.buffer),
|
|
1564
1678
|
() =>
|
|
1565
1679
|
explainStaleSiteContent({
|
|
1566
1680
|
client,
|
|
@@ -1568,7 +1682,7 @@ export async function pushSyncPackages({
|
|
|
1568
1682
|
localBuffer: siteContent.buffer,
|
|
1569
1683
|
uuid: siteContentUuid
|
|
1570
1684
|
}),
|
|
1571
|
-
{ boundUuid: siteContentUuid }
|
|
1685
|
+
{ boundUuid: siteContentUuid, reportTemplate: true }
|
|
1572
1686
|
)
|
|
1573
1687
|
if (!finalized) {
|
|
1574
1688
|
mergeHarvested()
|
|
@@ -1589,8 +1703,11 @@ export async function pushSyncPackages({
|
|
|
1589
1703
|
}
|
|
1590
1704
|
finalizedTotal += finalized.length
|
|
1591
1705
|
} else {
|
|
1592
|
-
const payload = await postLane(
|
|
1593
|
-
|
|
1706
|
+
const payload = await postLane(
|
|
1707
|
+
'site-content',
|
|
1708
|
+
() => client.createSiteContent(siteContent.buffer),
|
|
1709
|
+
undefined,
|
|
1710
|
+
{ reportTemplate: true }
|
|
1594
1711
|
)
|
|
1595
1712
|
if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
|
|
1596
1713
|
const minted = extractMintedSiteUuid(payload)
|
|
@@ -1611,13 +1728,7 @@ export async function pushSyncPackages({
|
|
|
1611
1728
|
// owner present or absent depending on whether the site happens to have images.
|
|
1612
1729
|
// The backend echoes `org`/`hosts_free` top-level here too, beside `report`
|
|
1613
1730
|
// and `site` (NOT beside `finalized`, which lives at report.finalized).
|
|
1614
|
-
const createdOrg = await recordAndDescribeOwner({
|
|
1615
|
-
client,
|
|
1616
|
-
siteDir,
|
|
1617
|
-
payload,
|
|
1618
|
-
asOrg,
|
|
1619
|
-
note
|
|
1620
|
-
})
|
|
1731
|
+
const createdOrg = await recordAndDescribeOwner({ client, siteDir, payload, note })
|
|
1621
1732
|
if (createdOrg)
|
|
1622
1733
|
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
1623
1734
|
const createdFinalized = extractFinalized(payload)
|
|
@@ -1640,7 +1751,7 @@ export async function pushSyncPackages({
|
|
|
1640
1751
|
}
|
|
1641
1752
|
const finalized = await pushLane(
|
|
1642
1753
|
'records',
|
|
1643
|
-
() => client.pushFolder(boundSiteUuid, records.buffer
|
|
1754
|
+
() => client.pushFolder(boundSiteUuid, records.buffer),
|
|
1644
1755
|
undefined,
|
|
1645
1756
|
{ boundUuid: boundSiteUuid }
|
|
1646
1757
|
)
|
|
@@ -1658,6 +1769,7 @@ export async function pushSyncPackages({
|
|
|
1658
1769
|
}
|
|
1659
1770
|
for (const w of bf.warnings) note(`! ${w}`)
|
|
1660
1771
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1772
|
+
draftsNotKept = bf.notKeptAsDrafts || []
|
|
1661
1773
|
if (bf.updated.length)
|
|
1662
1774
|
wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
1663
1775
|
// ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
|
|
@@ -1723,5 +1835,21 @@ export async function pushSyncPackages({
|
|
|
1723
1835
|
)
|
|
1724
1836
|
}
|
|
1725
1837
|
}
|
|
1838
|
+
// ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
|
|
1839
|
+
// piece of sync state above is banked: the push itself happened, and the next one must
|
|
1840
|
+
// see it. What must not happen is a publish that goes live on top of it, which is why
|
|
1841
|
+
// the exit code carries it (publish stops on anything but 0). The files keep their
|
|
1842
|
+
// `draft: true` (see backfillEntityUuids).
|
|
1843
|
+
if (draftsNotKept.length) {
|
|
1844
|
+
const one = draftsNotKept.length === 1
|
|
1845
|
+
const shown = draftsNotKept.map((p) => (isAbsolute(p) ? relative(siteDir, p) : p))
|
|
1846
|
+
error(
|
|
1847
|
+
`This backend did not keep ${one ? 'a record' : `${shown.length} records`} as a draft, ` +
|
|
1848
|
+
`so ${one ? 'it' : 'they'} would be delivered once the site is published:\n ` +
|
|
1849
|
+
shown.join('\n ') +
|
|
1850
|
+
'\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
|
|
1851
|
+
)
|
|
1852
|
+
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
|
|
1853
|
+
}
|
|
1726
1854
|
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
1727
1855
|
}
|