uniweb 0.64.1 → 0.66.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 +19 -19
- package/src/backend/client.js +61 -51
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +237 -243
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +20 -30
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +37 -43
- package/src/commands/pull.js +232 -81
- package/src/commands/push.js +36 -45
- package/src/commands/status.js +24 -15
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +39 -27
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +12 -9
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
- package/src/utils/semver-precedence.js +23 -0
package/src/backend/site-sync.js
CHANGED
|
@@ -15,10 +15,12 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from '
|
|
|
15
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
|
+
import { recordWritten } from '../utils/pull-written.js'
|
|
18
19
|
import { humanBytes } from '../utils/bytes.js'
|
|
19
20
|
import { isAuthoredPreview } from '../utils/preview.js'
|
|
20
21
|
import {
|
|
21
22
|
describeRequestError,
|
|
23
|
+
describeWorkspace,
|
|
22
24
|
WorkspaceMismatchError,
|
|
23
25
|
workspaceHandle,
|
|
24
26
|
workspaceHeader
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
diffSiteUnits,
|
|
33
35
|
describeSiteDiff,
|
|
34
36
|
computeUnitHashes,
|
|
37
|
+
collectSiteUnits,
|
|
35
38
|
collectUnitUuids,
|
|
36
39
|
collectFolderItemUuids,
|
|
37
40
|
collectQueryUuids,
|
|
@@ -90,6 +93,141 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
|
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
// A unit in the form it is compared in across the two representations: keys sorted,
|
|
97
|
+
// `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
|
|
98
|
+
const canonicalUnit = (v) =>
|
|
99
|
+
Array.isArray(v)
|
|
100
|
+
? v.map(canonicalUnit)
|
|
101
|
+
: v && typeof v === 'object'
|
|
102
|
+
? Object.fromEntries(
|
|
103
|
+
Object.keys(v)
|
|
104
|
+
.filter((k) => !k.startsWith('$'))
|
|
105
|
+
.sort()
|
|
106
|
+
.map((k) => [k, canonicalUnit(v[k])])
|
|
107
|
+
)
|
|
108
|
+
: v
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether the backend's post-write copy of a unit is the content we sent.
|
|
112
|
+
*
|
|
113
|
+
* Compared on OUR keys only. The two are not byte-comparable — the backend's copy
|
|
114
|
+
* carries fields we don't emit (`params`, `theme_override`, its own `$uuid`) and
|
|
115
|
+
* orders keys its own way, which is why `site-diff.js` never hashes one side against
|
|
116
|
+
* the other — but what we emit, it stores verbatim: measured 2026-09-23 against a
|
|
117
|
+
* live `uniwebd`, 15 of 15 units equal on our keys after a push.
|
|
118
|
+
*/
|
|
119
|
+
function writtenAsSent(sent, written) {
|
|
120
|
+
if (!sent || !written) return false
|
|
121
|
+
const onOurKeys = {}
|
|
122
|
+
for (const k of Object.keys(sent)) if (!k.startsWith('$')) onOurKeys[k] = written[k]
|
|
123
|
+
return JSON.stringify(canonicalUnit(sent)) === JSON.stringify(canonicalUnit(onOurKeys))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The post-write tokens a site-content push may bank: only those that describe
|
|
128
|
+
* content this copy HOLDS.
|
|
129
|
+
*
|
|
130
|
+
* ⛔ BANKING EVERY RETURNED TOKEN LOST OTHER WRITERS' WORK — measured 2026-09-23 on a
|
|
131
|
+
* live `uniwebd`, two copies of one site:
|
|
132
|
+
*
|
|
133
|
+
* 1. B edits section X and pushes.
|
|
134
|
+
* 2. A, which has not pulled, pushes an edit to section Y. The per-item gate reads
|
|
135
|
+
* A's X as untouched (`pkg == base`) and keeps B's X — correct. But the response
|
|
136
|
+
* carries X's token for B's content, and A banked it.
|
|
137
|
+
* 3. A's next push of X then reads as "only you changed it" (`host == base`) and
|
|
138
|
+
* overwrites B's edit, with no refusal.
|
|
139
|
+
*
|
|
140
|
+
* The entity token did the same to ADDITIONS: a section B added survived A's first
|
|
141
|
+
* push (created after A's base ⇒ KEEP), A banked a base newer than it, and A's next
|
|
142
|
+
* push deleted it (created at-or-before the base ⇒ "you dropped it").
|
|
143
|
+
*
|
|
144
|
+
* So a unit's token is banked only when its post-write copy is what we sent — our own
|
|
145
|
+
* write, or a unit nobody touched. A unit the backend kept for someone else keeps the
|
|
146
|
+
* token we already hold, which is the one for OUR content, so the next push that
|
|
147
|
+
* changes it is `pkg != base, host != base` and refused. And while the backend holds
|
|
148
|
+
* units we don't, the entity token stays where it was, so the next push keeps them.
|
|
149
|
+
*
|
|
150
|
+
* With either document missing there is nothing to compare, and the tokens are banked
|
|
151
|
+
* as the backend sent them — the contract is that `finalized[].document` is always
|
|
152
|
+
* there.
|
|
153
|
+
*
|
|
154
|
+
* @returns {{ version: string|null, itemVersions: Object<string,string>|null,
|
|
155
|
+
* held: string[], kept: string[], foreign: string[] }} `held`: units the backend
|
|
156
|
+
* holds as we sent them; `kept`: units it kept someone else's content for;
|
|
157
|
+
* `foreign`: units it holds that we did not send. All paths.
|
|
158
|
+
*/
|
|
159
|
+
export function heldTokens({ sent, written, version = null, itemVersions = null }) {
|
|
160
|
+
if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
|
|
161
|
+
const ours = collectSiteUnits(sent)
|
|
162
|
+
const theirs = collectSiteUnits(written)
|
|
163
|
+
const uuidAt = collectUnitUuids(written)
|
|
164
|
+
const units = new Set(Object.values(uuidAt))
|
|
165
|
+
const held = []
|
|
166
|
+
const kept = []
|
|
167
|
+
const foreign = []
|
|
168
|
+
const items = {}
|
|
169
|
+
for (const [path, uuid] of Object.entries(uuidAt)) {
|
|
170
|
+
if (!ours.has(path)) {
|
|
171
|
+
foreign.push(path)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (!writtenAsSent(ours.get(path), theirs.get(path))) {
|
|
175
|
+
kept.push(path)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
held.push(path)
|
|
179
|
+
if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
|
|
180
|
+
}
|
|
181
|
+
// A token for an item that is not a unit (a `queries` declaration) is never sent
|
|
182
|
+
// back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
|
|
183
|
+
// banked as before: it can neither arm nor disarm anything.
|
|
184
|
+
for (const [uuid, token] of Object.entries(itemVersions || {})) {
|
|
185
|
+
if (!units.has(uuid)) items[uuid] = token
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
version: foreign.length ? null : version,
|
|
189
|
+
itemVersions: itemVersions ? items : null,
|
|
190
|
+
held: held.sort(),
|
|
191
|
+
kept: kept.sort(),
|
|
192
|
+
foreign: foreign.sort()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The files a unit projects to, under the site's own roots — `site.yml::paths` can
|
|
197
|
+
// relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
|
|
198
|
+
// to three files.
|
|
199
|
+
function unitFilesOf(siteDir, unitPath) {
|
|
200
|
+
if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
|
|
201
|
+
let paths = {}
|
|
202
|
+
try {
|
|
203
|
+
paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
|
|
204
|
+
} catch {
|
|
205
|
+
/* no or unreadable site.yml — the defaults are right */
|
|
206
|
+
}
|
|
207
|
+
for (const root of ['pages', 'layout']) {
|
|
208
|
+
if (unitPath.startsWith(`${root}/`)) return [`${paths[root] || root}${unitPath.slice(root.length)}`]
|
|
209
|
+
}
|
|
210
|
+
return []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Record, as synced, the files of the units a push left on the backend as we sent them.
|
|
215
|
+
*
|
|
216
|
+
* ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
|
|
217
|
+
* version a file last synced at by the hash in `pull-written.json`. A pull records what
|
|
218
|
+
* it wrote; this records what a push delivered — the units `heldTokens` says the
|
|
219
|
+
* backend holds as ours, never one it kept for someone else. Without it, after a merge
|
|
220
|
+
* and a push the ancestor was the pull's raw output, which nobody commits: the next
|
|
221
|
+
* merge found no common version and put both whole files under conflict markers, for
|
|
222
|
+
* edits on different lines (measured 2026-09-23).
|
|
223
|
+
*/
|
|
224
|
+
function recordPushedUnits(siteDir, unitPaths) {
|
|
225
|
+
const files = new Set()
|
|
226
|
+
for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
|
|
227
|
+
const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
|
|
228
|
+
if (present.length) recordWritten(siteDir, present)
|
|
229
|
+
}
|
|
230
|
+
|
|
93
231
|
// Pull the finalized entities out of the restore response. The backend returns
|
|
94
232
|
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
|
|
95
233
|
// carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
|
|
@@ -111,10 +249,10 @@ export function extractFinalized(payload) {
|
|
|
111
249
|
uuid: d?.uuid ?? d?.document?.$uuid ?? null,
|
|
112
250
|
changed: d?.changed,
|
|
113
251
|
// Post-write optimistic-concurrency token, read back from the stored row.
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
252
|
+
// Do not branch on `changed`: the backend pins "zero-write ⇒ version unmoved"
|
|
253
|
+
// with a test, so a no-op resubmit returns the value we already hold. ⛔ But a
|
|
254
|
+
// site-content push banks it only while the stored document holds nothing we
|
|
255
|
+
// don't — see `heldTokens`, and what banking it unconditionally deleted.
|
|
118
256
|
version: typeof d?.version === 'string' ? d.version : null,
|
|
119
257
|
// Per-item post-write tokens, `{ recordUuid: <opaque> }` (backend `d7e46335`).
|
|
120
258
|
// Same map shape and same key name the PULL manifest entry stamps, deliberately
|
|
@@ -547,8 +685,12 @@ export function readItemBaseVersions(siteDir, backend) {
|
|
|
547
685
|
}
|
|
548
686
|
export function mergeItemBaseVersions(siteDir, backend, versions) {
|
|
549
687
|
if (!versions || !Object.keys(versions).length) return
|
|
688
|
+
// ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
|
|
689
|
+
// and this "merge" replaced the map with the last response — dropping the token
|
|
690
|
+
// `heldTokens` withholds for a unit the backend kept for someone else, and with it
|
|
691
|
+
// the precondition that refuses the next push of that unit (2026-09-23).
|
|
550
692
|
updateSyncCache(siteDir, backend, {
|
|
551
|
-
itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
|
|
693
|
+
itemBaseVersions: { ...readItemBaseVersions(siteDir, backend), ...versions }
|
|
552
694
|
})
|
|
553
695
|
}
|
|
554
696
|
export function mergeBaseVersions(siteDir, backend, versions) {
|
|
@@ -652,21 +794,19 @@ export function writeItemUuids(siteDir, backend, map) {
|
|
|
652
794
|
}
|
|
653
795
|
|
|
654
796
|
/**
|
|
655
|
-
* The workspace this
|
|
656
|
-
* `@handle`,
|
|
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.
|
|
797
|
+
* The workspace this site was created in on `backend` — its owner, as the create
|
|
798
|
+
* answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
|
|
799
|
+
* no handle, or null for the personal workspace (or no record).
|
|
663
800
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
* printed as "the site's org".
|
|
801
|
+
* ⭐ A RECORD, NOT A ROUTE (2026-09-23). A command works in the workspace chosen with the
|
|
802
|
+
* login (`workspace.js`); this says where the site was made, and routes nothing. (Until
|
|
803
|
+
* then it was the workspace every request named, adopted from a `409` when stale.)
|
|
668
804
|
*
|
|
669
|
-
*
|
|
805
|
+
* Read from `backend`'s section of `sync.json`: `site.org` holds a handle, stored bare
|
|
806
|
+
* and re-dressed with its `@`; `site.unit` holds a unit's uuid. At most one is set
|
|
807
|
+
* (`writeSiteWorkspace`). ⛔ Two keys, because the `@` is the only thing telling the two
|
|
808
|
+
* apart — our own handle grammar admits a uuid-shaped handle — and a bare value at rest
|
|
809
|
+
* has lost it.
|
|
670
810
|
*
|
|
671
811
|
* @param {string} siteDir
|
|
672
812
|
* @param {string} backend - the origin whose record to read
|
|
@@ -681,10 +821,10 @@ export function readSiteWorkspace(siteDir, backend) {
|
|
|
681
821
|
}
|
|
682
822
|
|
|
683
823
|
/**
|
|
684
|
-
* Record the workspace this
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
824
|
+
* Record the workspace this site was created in on `backend` — a handle in `site.org`
|
|
825
|
+
* (bare), a handle-less unit in `site.unit`, clearing the other; null clears both (the
|
|
826
|
+
* personal workspace, named by naming none). Returns what was recorded, in the header's
|
|
827
|
+
* form.
|
|
688
828
|
*
|
|
689
829
|
* @param {string} siteDir
|
|
690
830
|
* @param {string} backend
|
|
@@ -702,50 +842,6 @@ function writeSiteWorkspace(siteDir, backend, workspace) {
|
|
|
702
842
|
return w
|
|
703
843
|
}
|
|
704
844
|
|
|
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
845
|
/**
|
|
750
846
|
* Record the workspace a just-minted site was created under.
|
|
751
847
|
*
|
|
@@ -768,173 +864,6 @@ function recordSiteWorkspace(siteDir, backend, owner) {
|
|
|
768
864
|
}
|
|
769
865
|
}
|
|
770
866
|
|
|
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
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Resolve WHICH ORG will own a site that is about to be created.
|
|
780
|
-
*
|
|
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
|
|
783
|
-
* or delete a site. So this is a **one-shot, unrepealable** decision — and until
|
|
784
|
-
* this function existed the CLI made it silently, by sending nothing and letting
|
|
785
|
-
* the backend fall back to the session's personal context. A developer who belongs
|
|
786
|
-
* to several orgs could put a company site, and the storage it bills, somewhere
|
|
787
|
-
* they never named.
|
|
788
|
-
*
|
|
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 derived
|
|
791
|
-
* (`deriveScope`) — with one difference: non-interactive, it takes your personal
|
|
792
|
-
* scope and says so. This refuses instead, because a site's owner is the one-shot
|
|
793
|
-
* decision above, and a scope is only a namespace (2026-09-23). ⭐ 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
|
|
797
|
-
* `register` "has refused to guess a scope", which is wrong for a non-interactive
|
|
798
|
-
* run by anyone with an org.
|
|
799
|
-
*
|
|
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
|
|
803
|
-
* 3. the recorded org — `sync.json`, written at this site's own create
|
|
804
|
-
* 4. the site already exists on this backend (a recorded uuid) → null; ownership
|
|
805
|
-
* is settled, ask nothing
|
|
806
|
-
* 5. otherwise ASK (TTY) or REFUSE (non-interactive)
|
|
807
|
-
*
|
|
808
|
-
* ⛔ **`--personal` names no workspace, and is NOT the same as `--org @<handle>`.**
|
|
809
|
-
* The personal *org* `@jane` is an org like any other, lazily created on first use;
|
|
810
|
-
* the session's personal workspace is not an org at all. Whether the backend gives
|
|
811
|
-
* them the same owning unit is **its** business and unverified here, so the
|
|
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.
|
|
820
|
-
*/
|
|
821
|
-
export async function resolveSiteOrgForCreate({
|
|
822
|
-
client,
|
|
823
|
-
siteDir,
|
|
824
|
-
args = [],
|
|
825
|
-
flag,
|
|
826
|
-
personal = false,
|
|
827
|
-
offline = false
|
|
828
|
-
}) {
|
|
829
|
-
if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
|
|
830
|
-
if (personal) return { workspace: null, source: 'personal' }
|
|
831
|
-
|
|
832
|
-
const recorded = readSiteWorkspace(siteDir, client?.origin)
|
|
833
|
-
if (recorded) return { workspace: recorded, source: 'recorded' }
|
|
834
|
-
|
|
835
|
-
// Already created ⇒ nothing to decide: ownership was settled at its create and
|
|
836
|
-
// re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
|
|
837
|
-
// the same project may be brand new on one and long-established on another.
|
|
838
|
-
if (readBackendState(siteDir, client?.origin).site?.uuid) return { workspace: null, source: 'existing' }
|
|
839
|
-
|
|
840
|
-
// An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
|
|
841
|
-
// creating nothing, so there is no decision to force. Say what is unresolved
|
|
842
|
-
// instead of prompting for an answer the run will not use.
|
|
843
|
-
if (offline) return { workspace: null, source: 'offline' }
|
|
844
|
-
|
|
845
|
-
// `--yes` promises "never block on a prompt", so it has to answer this one too —
|
|
846
|
-
// and the only honest non-blocking answer to an unanswerable ownership question
|
|
847
|
-
// is a refusal. Treating it as consent-to-anything would reinstate the silent
|
|
848
|
-
// default this whole path exists to remove, behind a flag that reads like
|
|
849
|
-
// approval.
|
|
850
|
-
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
851
|
-
if (isNonInteractive(args) || args.includes('--yes')) {
|
|
852
|
-
return {
|
|
853
|
-
workspace: null,
|
|
854
|
-
refused: true,
|
|
855
|
-
reason:
|
|
856
|
-
'This site does not exist on the backend yet, and no org was named.\n' +
|
|
857
|
-
' The create decides who OWNS it — and which workspace its storage is billed to —\n' +
|
|
858
|
-
' one time, with no CLI way to change it afterwards. Name it explicitly:\n' +
|
|
859
|
-
' --org @org create it under an organization\n' +
|
|
860
|
-
' --personal create it under your personal account, deliberately'
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one picks
|
|
865
|
-
// a NAMESPACE for a foundation, where `@<your handle>` is your personal scope. Here
|
|
866
|
-
// the answer is an OWNER: your personal workspace (named by naming none) or an org.
|
|
867
|
-
const { fetchOrgs, createOrg, validateHandle, bareHandle } =
|
|
868
|
-
await import('../utils/registry-orgs.js')
|
|
869
|
-
let envelope
|
|
870
|
-
try {
|
|
871
|
-
envelope = await fetchOrgs({
|
|
872
|
-
apiBase: client.origin,
|
|
873
|
-
token: await client.token()
|
|
874
|
-
})
|
|
875
|
-
} catch (err) {
|
|
876
|
-
return { workspace: null, refused: true, reason: err.message }
|
|
877
|
-
}
|
|
878
|
-
const personalHandle = envelope.account_handle || null
|
|
879
|
-
const prompts = (await import('prompts')).default
|
|
880
|
-
const choices = [
|
|
881
|
-
...envelope.orgs.map((o) => ({
|
|
882
|
-
title: `@${o.handle}${o.handle === personalHandle ? ' — your personal org' : o.is_primary ? ' (primary)' : ''}`,
|
|
883
|
-
value: o.handle
|
|
884
|
-
})),
|
|
885
|
-
{
|
|
886
|
-
title: `Personal — no organization${personalHandle ? ` (${personalHandle})` : ''}`,
|
|
887
|
-
value: ':personal'
|
|
888
|
-
},
|
|
889
|
-
{ title: 'A new organization…', value: ':new' }
|
|
890
|
-
]
|
|
891
|
-
const { choice } = await prompts(
|
|
892
|
-
{
|
|
893
|
-
type: 'select',
|
|
894
|
-
name: 'choice',
|
|
895
|
-
message: 'Create this site under which owner?',
|
|
896
|
-
choices,
|
|
897
|
-
initial: 0
|
|
898
|
-
},
|
|
899
|
-
{
|
|
900
|
-
onCancel: () => {
|
|
901
|
-
console.log('\nCancelled.')
|
|
902
|
-
process.exit(0)
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
)
|
|
906
|
-
if (!choice) return { workspace: null, refused: true, reason: 'No owner chosen.' }
|
|
907
|
-
if (choice === ':personal') return { workspace: null, source: 'picked' }
|
|
908
|
-
if (choice !== ':new') return { workspace: `@${choice}`, source: 'picked' }
|
|
909
|
-
|
|
910
|
-
const answer = await prompts(
|
|
911
|
-
{
|
|
912
|
-
type: 'text',
|
|
913
|
-
name: 'handle',
|
|
914
|
-
message: 'Org handle (e.g. acme):',
|
|
915
|
-
validate: (v) => validateHandle(v) || true
|
|
916
|
-
},
|
|
917
|
-
{
|
|
918
|
-
onCancel: () => {
|
|
919
|
-
console.log('\nCancelled.')
|
|
920
|
-
process.exit(0)
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
)
|
|
924
|
-
if (!answer.handle)
|
|
925
|
-
return { workspace: null, refused: true, reason: 'No org handle given.' }
|
|
926
|
-
try {
|
|
927
|
-
const org = await createOrg({
|
|
928
|
-
apiBase: client.origin,
|
|
929
|
-
token: await client.token(),
|
|
930
|
-
handle: bareHandle(answer.handle)
|
|
931
|
-
})
|
|
932
|
-
return { workspace: `@${org.handle}`, source: 'picked' }
|
|
933
|
-
} catch (err) {
|
|
934
|
-
return { workspace: null, refused: true, reason: err.message }
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
867
|
/**
|
|
939
868
|
* Turn a typed site-create refusal into a sentence, or null to fall through to the
|
|
940
869
|
* generic HTTP line.
|
|
@@ -1048,7 +977,7 @@ export async function ensureSiteExists({
|
|
|
1048
977
|
|
|
1049
978
|
let res
|
|
1050
979
|
try {
|
|
1051
|
-
// The owner is the workspace the client names —
|
|
980
|
+
// The owner is the workspace the client names — the command's (`workspace.js`).
|
|
1052
981
|
res = await client.createSite({
|
|
1053
982
|
name: siteName,
|
|
1054
983
|
foundation: siteFoundation
|
|
@@ -1147,6 +1076,31 @@ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
|
|
|
1147
1076
|
return org
|
|
1148
1077
|
}
|
|
1149
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Stop a push or publish before it sends anything, when a record cannot be sent as
|
|
1081
|
+
* written — the emit's `refusals` (`@uniweb/build`'s `recordsToEntities`).
|
|
1082
|
+
*
|
|
1083
|
+
* ⛔ Checked BEFORE the site is created or a byte uploaded, not only before the send:
|
|
1084
|
+
* both are writes of their own, which a refused records lane does not undo. (The lane
|
|
1085
|
+
* itself is one transaction on the backend since 2026-09-24, so a refusal there writes
|
|
1086
|
+
* nothing. Before that, a refused lane left the folder's entries and entities with no
|
|
1087
|
+
* data behind, and every later push of the site's records was refused — measured
|
|
1088
|
+
* 2026-09-23, from one record written by section.)
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string[]|undefined} refusals
|
|
1091
|
+
* @param {{ error: (m: string) => void, note: (m: string) => void }} report
|
|
1092
|
+
* @returns {boolean} true when the caller must stop
|
|
1093
|
+
*/
|
|
1094
|
+
export function refuseUnsendableRecords(refusals, { error, note }) {
|
|
1095
|
+
if (!refusals?.length) return false
|
|
1096
|
+
const n = refusals.length
|
|
1097
|
+
error(
|
|
1098
|
+
`${n === 1 ? 'A record' : `${n} records`} cannot be pushed as written — no content was sent.`
|
|
1099
|
+
)
|
|
1100
|
+
for (const r of refusals) note(` ${r}`)
|
|
1101
|
+
return true
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1150
1104
|
/**
|
|
1151
1105
|
* Guarantee we have per-item identity before an identity-bearing push.
|
|
1152
1106
|
*
|
|
@@ -1652,11 +1606,11 @@ export async function pushSyncPackages({
|
|
|
1652
1606
|
const harvest = (finalized) => {
|
|
1653
1607
|
for (const f of finalized || []) {
|
|
1654
1608
|
if (f.uuid && f.version) newVersions[f.uuid] = f.version
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
// field entirely, which leaves the cached tokens
|
|
1659
|
-
// entity grain, exactly as before.
|
|
1609
|
+
// NOT gated on `changed` — same rule as the entity token: the backend pins
|
|
1610
|
+
// "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
|
|
1611
|
+
// already hold. (The site-content lane filters first, `harvestSiteContent`.)
|
|
1612
|
+
// An older backend omits the field entirely, which leaves the cached tokens
|
|
1613
|
+
// alone and degrades to the entity grain, exactly as before.
|
|
1660
1614
|
if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
|
|
1661
1615
|
}
|
|
1662
1616
|
}
|
|
@@ -1665,6 +1619,27 @@ export async function pushSyncPackages({
|
|
|
1665
1619
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
1666
1620
|
mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
|
|
1667
1621
|
}
|
|
1622
|
+
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
1623
|
+
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
1624
|
+
// losses banking everything caused). What the backend kept for someone else is
|
|
1625
|
+
// collected here, to re-base attribution and to say so.
|
|
1626
|
+
const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
|
|
1627
|
+
const notHeld = { kept: [], foreign: [] }
|
|
1628
|
+
const heldUnits = []
|
|
1629
|
+
const harvestSiteContent = (finalized) => {
|
|
1630
|
+
for (const f of finalized || []) {
|
|
1631
|
+
const held = heldTokens({
|
|
1632
|
+
sent: sentSiteDoc,
|
|
1633
|
+
written: f.document,
|
|
1634
|
+
version: f.version,
|
|
1635
|
+
itemVersions: f.itemVersions
|
|
1636
|
+
})
|
|
1637
|
+
harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
|
|
1638
|
+
heldUnits.push(...held.held)
|
|
1639
|
+
notHeld.kept.push(...held.kept)
|
|
1640
|
+
notHeld.foreign.push(...held.foreign)
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1668
1643
|
// The backend's post-write copy of the site-content document, kept for the
|
|
1669
1644
|
// remote-side unit base (see writeUnitBases).
|
|
1670
1645
|
let siteFinalizedDoc = null
|
|
@@ -1687,7 +1662,7 @@ export async function pushSyncPackages({
|
|
|
1687
1662
|
mergeHarvested()
|
|
1688
1663
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
1689
1664
|
}
|
|
1690
|
-
|
|
1665
|
+
harvestSiteContent(finalized)
|
|
1691
1666
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
1692
1667
|
// ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
|
|
1693
1668
|
// placements below. These items have no file to back-fill into — they all
|
|
@@ -1731,7 +1706,7 @@ export async function pushSyncPackages({
|
|
|
1731
1706
|
if (createdOrg)
|
|
1732
1707
|
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
1733
1708
|
const createdFinalized = extractFinalized(payload)
|
|
1734
|
-
|
|
1709
|
+
harvestSiteContent(createdFinalized)
|
|
1735
1710
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
1736
1711
|
finalizedTotal += createdFinalized?.length ?? 1
|
|
1737
1712
|
}
|
|
@@ -1801,12 +1776,21 @@ export async function pushSyncPackages({
|
|
|
1801
1776
|
// actually shipped — a push that skipped it left that state where it was.
|
|
1802
1777
|
if (siteContent) {
|
|
1803
1778
|
const patch = {}
|
|
1804
|
-
const ours =
|
|
1779
|
+
const ours = sentSiteDoc
|
|
1805
1780
|
if (ours) patch.local = computeUnitHashes(ours)
|
|
1806
1781
|
// `finalized[].document` is the backend's own representation, read back from
|
|
1807
1782
|
// the stored row — the only remote-side base a push can produce.
|
|
1808
1783
|
const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
|
|
1809
|
-
if (theirs)
|
|
1784
|
+
if (theirs) {
|
|
1785
|
+
patch.remote = computeUnitHashes(theirs)
|
|
1786
|
+
// A unit the backend kept for someone else is not agreed state: its remote base
|
|
1787
|
+
// stays where it was, so a refusal names it as changed upstream.
|
|
1788
|
+
const before = readUnitBases(siteDir, client.origin).remote
|
|
1789
|
+
for (const path of notHeld.kept) {
|
|
1790
|
+
if (before[path]) patch.remote[path] = before[path]
|
|
1791
|
+
else delete patch.remote[path]
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1810
1794
|
if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
|
|
1811
1795
|
// The backend's post-write document carries `$uuid` per item at every nesting
|
|
1812
1796
|
// level, so the push that just landed also re-arms identity for the next one.
|
|
@@ -1834,6 +1818,16 @@ export async function pushSyncPackages({
|
|
|
1834
1818
|
)
|
|
1835
1819
|
}
|
|
1836
1820
|
}
|
|
1821
|
+
// The files of the units the backend now holds as ours are the synced version — the
|
|
1822
|
+
// ancestor the next `pull --merge` merges from.
|
|
1823
|
+
if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
|
|
1824
|
+
// What the backend holds that this copy doesn't. None of it was overwritten, and no
|
|
1825
|
+
// later push will overwrite it — but the files are behind until a pull.
|
|
1826
|
+
if (notHeld.kept.length || notHeld.foreign.length) {
|
|
1827
|
+
const paths = [...new Set([...notHeld.kept, ...notHeld.foreign])].sort()
|
|
1828
|
+
note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
|
|
1829
|
+
note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
|
|
1830
|
+
}
|
|
1837
1831
|
// ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
|
|
1838
1832
|
// piece of sync state above is banked: the push itself happened, and the next one must
|
|
1839
1833
|
// see it. What must not happen is a publish that goes live on top of it, which is why
|