uniweb 0.28.1 → 0.30.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 +6 -2
- package/src/backend/client.js +29 -4
- package/src/backend/site-sync.js +31 -2
- package/src/commands/clone.js +9 -0
- package/src/commands/publish.js +20 -0
- package/src/commands/pull.js +21 -0
- package/src/commands/push.js +34 -0
- package/src/commands/status.js +2 -0
- package/src/framework-index.json +9 -9
- package/src/utils/config.js +12 -1
- package/src/utils/site-identity.js +234 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/core": "^0.
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/kit": "^0.13.5",
|
|
45
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
46
|
+
"@uniweb/core": "^0.12.0",
|
|
47
|
+
"@uniweb/runtime": "^0.12.10"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "^0.26.0",
|
|
51
51
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2128,8 +2128,12 @@ happened into a journey.
|
|
|
2128
2128
|
> or tabs, and **nothing is written to the visitor's device** — no cookie, no
|
|
2129
2129
|
> local storage. The `visit` key lives in memory and dies with the document, so
|
|
2130
2130
|
> it identifies one page load rather than a person. A `page_view` carries the
|
|
2131
|
-
> path
|
|
2132
|
-
>
|
|
2131
|
+
> path; two booleans about the page load — `first_of_load` on the view that
|
|
2132
|
+
> opened the document, and `continues` when the load came from one of your own
|
|
2133
|
+
> pages; and — captured once when the page first loads, then replayed on each
|
|
2134
|
+
> view — the external referrer and any `utm_*` the visitor arrived with. Nothing
|
|
2135
|
+
> else. **Both booleans describe the load, never the visitor**, and neither links
|
|
2136
|
+
> two loads to each other.
|
|
2133
2137
|
|
|
2134
2138
|
### ⛔ Use kit. Never touch the `uniweb` global
|
|
2135
2139
|
|
package/src/backend/client.js
CHANGED
|
@@ -48,16 +48,35 @@ import { uploadSiteAssets } from '../utils/asset-upload.js'
|
|
|
48
48
|
*
|
|
49
49
|
* 1. `flag` — the raw --backend / --registry value (this command)
|
|
50
50
|
* 2. UNIWEB_REGISTER_URL env — session-wide override (CI / local dev)
|
|
51
|
-
* 3. `
|
|
52
|
-
* 4
|
|
51
|
+
* 3. `siteScope` — the project's `site.yml::$backend` (site verbs)
|
|
52
|
+
* 4. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
|
|
53
|
+
* 5–7. the logged-in session origin > ~/.uniweb/config.json > the default
|
|
53
54
|
* (uniweb.app) — all via getRegistryApiBaseUrl()
|
|
54
55
|
*
|
|
56
|
+
* ⭐ **`siteScope` outranks the SESSION, and that is the whole reason it is a tier.** The
|
|
57
|
+
* session is a machine-wide default; `$backend` is this project's statement about where
|
|
58
|
+
* its stored identity is meaningful. Without the tier, a teammate who clones a project
|
|
59
|
+
* bound to a non-default backend resolves to whatever they last logged into, and is then
|
|
60
|
+
* REFUSED by `assertSiteBackendScope` — nagged instead of routed, on a question the
|
|
61
|
+
* project already answered.
|
|
62
|
+
*
|
|
63
|
+
* ⭐ **And it outranks `siteBackend`, which looks like the same fact and is not.**
|
|
64
|
+
* `deploy.yml`'s target `backend` records *where this ships*; `$backend` records *whose
|
|
65
|
+
* namespace the uuid is in*. They agree in the normal case. When they disagree the
|
|
66
|
+
* identity wins, because resolving to the other one only reaches the guard and stops.
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ The explicit overrides stay ON TOP deliberately. `--backend` and the env var are how
|
|
69
|
+
* you deliberately aim elsewhere — at a staging mirror, say — and a project file must not
|
|
70
|
+
* be able to veto a flag the user just typed. A wrong aim is then caught by the guard
|
|
71
|
+
* rather than silently redirected.
|
|
72
|
+
*
|
|
55
73
|
* @param {string} [flag] - the raw value of --backend / --registry, if supplied
|
|
56
74
|
* @param {object} [opts]
|
|
75
|
+
* @param {string} [opts.siteScope] - the project's `site.yml::$backend`
|
|
57
76
|
* @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
|
|
58
77
|
* @returns {string} a bare origin with no trailing slash
|
|
59
78
|
*/
|
|
60
|
-
export function resolveBackendOrigin(flag, { siteBackend } = {}) {
|
|
79
|
+
export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
|
|
61
80
|
const norm = (v) => {
|
|
62
81
|
try {
|
|
63
82
|
return new URL(v).origin
|
|
@@ -74,6 +93,10 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
|
|
|
74
93
|
const o = norm(env)
|
|
75
94
|
if (o) return o
|
|
76
95
|
}
|
|
96
|
+
if (siteScope) {
|
|
97
|
+
const o = norm(siteScope)
|
|
98
|
+
if (o) return o
|
|
99
|
+
}
|
|
77
100
|
if (siteBackend) {
|
|
78
101
|
const o = norm(siteBackend)
|
|
79
102
|
if (o) return o
|
|
@@ -116,6 +139,7 @@ export class BackendClient {
|
|
|
116
139
|
* @param {object} [opts]
|
|
117
140
|
* @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
|
|
118
141
|
* @param {string} [opts.originFlag] - raw --backend/--registry value to resolve
|
|
142
|
+
* @param {string} [opts.siteScope] - the project's `site.yml::$backend` (site verbs)
|
|
119
143
|
* @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
|
|
120
144
|
* @param {string} [opts.token] - explicit bearer (wins over env + stored session)
|
|
121
145
|
* @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
|
|
@@ -127,6 +151,7 @@ export class BackendClient {
|
|
|
127
151
|
constructor({
|
|
128
152
|
origin,
|
|
129
153
|
originFlag,
|
|
154
|
+
siteScope,
|
|
130
155
|
siteBackend,
|
|
131
156
|
token,
|
|
132
157
|
getToken,
|
|
@@ -135,7 +160,7 @@ export class BackendClient {
|
|
|
135
160
|
fetchImpl
|
|
136
161
|
} = {}) {
|
|
137
162
|
this.origin = (
|
|
138
|
-
origin || resolveBackendOrigin(originFlag, { siteBackend })
|
|
163
|
+
origin || resolveBackendOrigin(originFlag, { siteScope, siteBackend })
|
|
139
164
|
).replace(/\/+$/, '')
|
|
140
165
|
this._token = token || process.env.UNIWEB_TOKEN || null
|
|
141
166
|
this._getToken = getToken || null
|
package/src/backend/site-sync.js
CHANGED
|
@@ -15,6 +15,7 @@ import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
|
|
|
15
15
|
import { join, dirname } from 'node:path'
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
|
+
import { recordSiteBackend } from '../utils/site-identity.js'
|
|
18
19
|
import {
|
|
19
20
|
backfillEntityUuids,
|
|
20
21
|
writeSiteEntityUuid,
|
|
@@ -796,6 +797,16 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
|
|
|
796
797
|
echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
|
|
797
798
|
const org = recordSiteOrg(siteDir, owner)
|
|
798
799
|
|
|
800
|
+
// The SYNC SCOPE, recorded here for the same reason `$org` is: this function is the
|
|
801
|
+
// one place BOTH create paths meet (`ensureSiteExists` for a site with local media,
|
|
802
|
+
// the content-lane create for one without). Recording it at either call site instead
|
|
803
|
+
// would make `$backend` present or absent depending on whether the site happens to
|
|
804
|
+
// have images — the exact drift the comment at the second call site warns about.
|
|
805
|
+
//
|
|
806
|
+
// A no-op on the default backend, so the common case writes nothing.
|
|
807
|
+
const scope = await recordSiteBackend(siteDir, client.origin)
|
|
808
|
+
if (scope) note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
|
|
809
|
+
|
|
799
810
|
note?.(
|
|
800
811
|
org
|
|
801
812
|
? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
|
|
@@ -1088,11 +1099,29 @@ export async function pushSyncPackages({
|
|
|
1088
1099
|
// verb that could have removed it — a site is deleted in the Uniweb app,
|
|
1089
1100
|
// and that is what severs the sync — so the raw 404 leaves the user with
|
|
1090
1101
|
// no idea that the fix is local and one line.
|
|
1102
|
+
//
|
|
1103
|
+
// ⛔ ORDER THESE TWO CAUSES, and put the RECOVERABLE one first. Until 2026-08-24
|
|
1104
|
+
// this branch named only the deletion and went straight to "clear `$uuid`" — and
|
|
1105
|
+
// that advice, followed for the OTHER cause, destroys a live binding: the site is
|
|
1106
|
+
// fine, you are simply pointed at the wrong backend, and clearing the uuid orphans
|
|
1107
|
+
// it. `assertSiteBackendScope` now catches most of that before any request goes
|
|
1108
|
+
// out, so reaching here usually does mean a deletion; "usually" is not "always"
|
|
1109
|
+
// (a project predating `$backend` records no scope to check), which is why the
|
|
1110
|
+
// cheap cause is still named before the destructive fix.
|
|
1111
|
+
note(
|
|
1112
|
+
`The backend at ${client.origin} has no site with uuid ${boundUuid}.`
|
|
1113
|
+
)
|
|
1114
|
+
note(
|
|
1115
|
+
'Two causes. Check the cheap one first: is this the backend the site lives on?'
|
|
1116
|
+
)
|
|
1117
|
+
note(
|
|
1118
|
+
` wrong backend → uniweb login --backend <the right one> (nothing is lost)`
|
|
1119
|
+
)
|
|
1091
1120
|
note(
|
|
1092
|
-
`
|
|
1121
|
+
` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
|
|
1093
1122
|
)
|
|
1094
1123
|
note(
|
|
1095
|
-
'Deleting this folder removes only your local copy
|
|
1124
|
+
'Deleting this folder removes only your local copy, either way.'
|
|
1096
1125
|
)
|
|
1097
1126
|
} else if (res.status === 409) {
|
|
1098
1127
|
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
package/src/commands/clone.js
CHANGED
|
@@ -59,6 +59,7 @@ import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
|
59
59
|
import { readUwxDocuments } from '../utils/uwx-read.js'
|
|
60
60
|
import { recordWritten } from '../utils/pull-written.js'
|
|
61
61
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
62
|
+
import { recordSiteBackend } from '../utils/site-identity.js'
|
|
62
63
|
|
|
63
64
|
const colors = {
|
|
64
65
|
reset: '\x1b[0m',
|
|
@@ -335,9 +336,17 @@ export async function clone(args = [], deps = {}) {
|
|
|
335
336
|
// same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
|
|
336
337
|
// separate folder uuid to seed.
|
|
337
338
|
seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
|
|
339
|
+
// …and the SYNC SCOPE that makes it meaningful. `clone` is the sharpest case for it:
|
|
340
|
+
// the uuid comes from whoever we read, and until now nothing on disk recorded that —
|
|
341
|
+
// so a teammate who cloned this project and ran `uniweb pull` while logged in
|
|
342
|
+
// elsewhere sent this backend's uuid to a different one. A no-op on the default
|
|
343
|
+
// backend. (The two CREATE paths record it via `recordAndDescribeOwner`; clone seeds
|
|
344
|
+
// an existing site and never reaches them, which is why it is written here too.)
|
|
345
|
+
const scope = await recordSiteBackend(siteDir, client.origin)
|
|
338
346
|
success(
|
|
339
347
|
`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
|
|
340
348
|
)
|
|
349
|
+
if (scope) note(`Bound to ${scope} (recorded $backend in site.yml).`)
|
|
341
350
|
|
|
342
351
|
// 5. Install, then delegate the projection to the project-local `uniweb pull`.
|
|
343
352
|
const pm = detectWorkspacePm(projectDir) || 'pnpm'
|
package/src/commands/publish.js
CHANGED
|
@@ -65,6 +65,10 @@ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
|
65
65
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
66
66
|
import { readFlagValue, readOrgFlag } from '../utils/args.js'
|
|
67
67
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
68
|
+
import {
|
|
69
|
+
assertSiteBackendScope,
|
|
70
|
+
readSiteIdentity
|
|
71
|
+
} from '../utils/site-identity.js'
|
|
68
72
|
import { isNonInteractive, confirm } from '../utils/interactive.js'
|
|
69
73
|
import { headProvenance } from '../utils/git.js'
|
|
70
74
|
import {
|
|
@@ -180,17 +184,33 @@ export async function publish(args = []) {
|
|
|
180
184
|
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
181
185
|
// The site's deploy.yml-bound backend (where it was published) feeds the
|
|
182
186
|
// resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
|
|
187
|
+
// The project's own statement of where its identity lives. Feeds the origin ladder
|
|
188
|
+
// ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
|
|
189
|
+
// targets the backend it is bound to instead of whatever they last logged into.
|
|
190
|
+
// ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
|
|
191
|
+
const siteScope = readSiteIdentity(siteDir).backend
|
|
183
192
|
const siteBackend = await resolveSiteBackend(siteDir)
|
|
184
193
|
|
|
185
194
|
const client = new BackendClient({
|
|
186
195
|
originFlag:
|
|
187
196
|
readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
197
|
+
siteScope,
|
|
188
198
|
siteBackend,
|
|
189
199
|
token: readFlagValue(args, '--token') || undefined,
|
|
190
200
|
args,
|
|
191
201
|
command: 'Publishing'
|
|
192
202
|
})
|
|
193
203
|
|
|
204
|
+
// ⛔ SCOPE CHECK — before the foundation bring-along, the sync, or the go-live. A
|
|
205
|
+
// publish is the longest of these flows and the most expensive to unwind, so it is
|
|
206
|
+
// the one that most benefits from failing at the first step rather than at the fifth.
|
|
207
|
+
const scope = assertSiteBackendScope(siteDir, client.origin)
|
|
208
|
+
if (!scope.ok) {
|
|
209
|
+
say.err(scope.message)
|
|
210
|
+
scope.hint.forEach(say.dim)
|
|
211
|
+
return { exitCode: 1 }
|
|
212
|
+
}
|
|
213
|
+
|
|
194
214
|
// WHO will own this site, if this publish is the one that creates it. Resolved
|
|
195
215
|
// up front: `ensureSiteExists` below is the create, and it must not be reached
|
|
196
216
|
// with the question still open. An already-created site resolves to null without
|
package/src/commands/pull.js
CHANGED
|
@@ -102,6 +102,10 @@ import {
|
|
|
102
102
|
resolveSiteBackend
|
|
103
103
|
} from './deploy.js'
|
|
104
104
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
105
|
+
import {
|
|
106
|
+
assertSiteBackendScope,
|
|
107
|
+
readSiteIdentity
|
|
108
|
+
} from '../utils/site-identity.js'
|
|
105
109
|
|
|
106
110
|
const FOLDER_MODEL = '@uniweb/folder'
|
|
107
111
|
|
|
@@ -549,9 +553,15 @@ export async function pull(args = [], deps = {}) {
|
|
|
549
553
|
const blocked = await checkWorkingTree(siteDir, args)
|
|
550
554
|
if (blocked) return blocked
|
|
551
555
|
}
|
|
556
|
+
// The project's own statement of where its identity lives. Feeds the origin ladder
|
|
557
|
+
// ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
|
|
558
|
+
// targets the backend it is bound to instead of whatever they last logged into.
|
|
559
|
+
// ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
|
|
560
|
+
const siteScope = readSiteIdentity(siteDir).backend
|
|
552
561
|
const siteBackend = await resolveSiteBackend(siteDir)
|
|
553
562
|
const client = new BackendClient({
|
|
554
563
|
originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
|
|
564
|
+
siteScope,
|
|
555
565
|
siteBackend,
|
|
556
566
|
token: tokenFlag,
|
|
557
567
|
getToken: deps.getToken,
|
|
@@ -559,6 +569,17 @@ export async function pull(args = [], deps = {}) {
|
|
|
559
569
|
args,
|
|
560
570
|
command: 'Pulling'
|
|
561
571
|
})
|
|
572
|
+
|
|
573
|
+
// ⛔ SCOPE CHECK — before the lanes read. `pull` WRITES the working tree from what it
|
|
574
|
+
// fetches, so a wrong-backend pull is not merely a failed read: it is the case that
|
|
575
|
+
// most needs stopping early.
|
|
576
|
+
const scope = assertSiteBackendScope(siteDir, client.origin)
|
|
577
|
+
if (!scope.ok) {
|
|
578
|
+
error(scope.message)
|
|
579
|
+
scope.hint.forEach(note)
|
|
580
|
+
return { exitCode: 1 }
|
|
581
|
+
}
|
|
582
|
+
|
|
562
583
|
// One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
|
|
563
584
|
// by it — the backend resolves the site's `@uniweb/folder` from this uuid.
|
|
564
585
|
const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
|
package/src/commands/push.js
CHANGED
|
@@ -72,6 +72,10 @@ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
|
72
72
|
import { reportSchemalessCollections } from '../utils/schemaless-report.js'
|
|
73
73
|
import { readOrgFlag } from '../utils/args.js'
|
|
74
74
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
75
|
+
import {
|
|
76
|
+
assertSiteBackendScope,
|
|
77
|
+
readSiteIdentity
|
|
78
|
+
} from '../utils/site-identity.js'
|
|
75
79
|
import { confirm } from '../utils/interactive.js'
|
|
76
80
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
77
81
|
import {
|
|
@@ -149,6 +153,11 @@ export async function push(args = [], deps = {}) {
|
|
|
149
153
|
// Advisory only — warns and pushes. A malformed data block otherwise rides
|
|
150
154
|
// the sync wire unchecked; see utils/conformance.js.
|
|
151
155
|
await warnIfContentDoesNotConform(siteDir, { args })
|
|
156
|
+
// The project's own statement of where its identity lives. Feeds the origin ladder
|
|
157
|
+
// ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
|
|
158
|
+
// targets the backend it is bound to instead of whatever they last logged into.
|
|
159
|
+
// ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
|
|
160
|
+
const siteScope = readSiteIdentity(siteDir).backend
|
|
152
161
|
const siteBackend = await resolveSiteBackend(siteDir)
|
|
153
162
|
// One front door. The bearer is resolved lazily on first need (a non-local Model
|
|
154
163
|
// read during the build, or the submit). Offline emit (--dry-run / -o) is fully
|
|
@@ -157,12 +166,37 @@ export async function push(args = [], deps = {}) {
|
|
|
157
166
|
// references a Model the local foundation doesn't define.
|
|
158
167
|
const client = new BackendClient({
|
|
159
168
|
originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
|
|
169
|
+
siteScope,
|
|
160
170
|
siteBackend,
|
|
161
171
|
token: tokenFlag,
|
|
162
172
|
args,
|
|
163
173
|
command: 'Syncing'
|
|
164
174
|
})
|
|
165
175
|
|
|
176
|
+
// ⛔ SCOPE CHECK — before anything is sent. A project whose stored identity was minted
|
|
177
|
+
// by a different backend cannot be pushed here: the uuids, the asset ids and the sync
|
|
178
|
+
// cache are all foreign at once. Runs after the client so it sees the RESOLVED origin
|
|
179
|
+
// (flag > env > deploy.yml > session), not the one we guessed.
|
|
180
|
+
//
|
|
181
|
+
// ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all. Its output is
|
|
182
|
+
// built from files on disk; the resolved origin is not an input to it, so a mismatch
|
|
183
|
+
// cannot make the artifact wrong — and refusing would break an operation this command
|
|
184
|
+
// deliberately keeps offline (the `!output && !dryRun` guards below are the same rule).
|
|
185
|
+
// The refusal even says "Sending them elsewhere is refused" over a run that sends
|
|
186
|
+
// nothing.
|
|
187
|
+
//
|
|
188
|
+
// `--dry-run` IS checked, and the asymmetry is the point: a dry run previews a real
|
|
189
|
+
// push, so when that push would be refused, saying so is the honest preview. Printing
|
|
190
|
+
// "would update content at <origin>" instead would preview something that cannot happen.
|
|
191
|
+
if (!output) {
|
|
192
|
+
const scope = assertSiteBackendScope(siteDir, client.origin)
|
|
193
|
+
if (!scope.ok) {
|
|
194
|
+
error(scope.message)
|
|
195
|
+
scope.hint.forEach(note)
|
|
196
|
+
return { exitCode: 1 }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
166
200
|
// WHO will own this site, if this push is the one that creates it. Resolved
|
|
167
201
|
// before any lane runs, because both create paths below consume it and neither
|
|
168
202
|
// should be reached with the question still open. A site that already exists
|
package/src/commands/status.js
CHANGED
|
@@ -31,6 +31,7 @@ import { readFlagValue } from '../utils/args.js'
|
|
|
31
31
|
import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
|
|
32
32
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
33
33
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
34
|
+
import { readSiteIdentity } from '../utils/site-identity.js'
|
|
34
35
|
|
|
35
36
|
const c = {
|
|
36
37
|
reset: '\x1b[0m',
|
|
@@ -109,6 +110,7 @@ export async function status(args = []) {
|
|
|
109
110
|
const client = new BackendClient({
|
|
110
111
|
originFlag:
|
|
111
112
|
readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
113
|
+
siteScope: readSiteIdentity(siteDir).backend,
|
|
112
114
|
siteBackend: await resolveSiteBackend(siteDir),
|
|
113
115
|
token: readFlagValue(args, '--token') || undefined,
|
|
114
116
|
args,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-25T21:53:35.373Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.26.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"deps": []
|
|
30
30
|
},
|
|
31
31
|
"@uniweb/core": {
|
|
32
|
-
"version": "0.
|
|
32
|
+
"version": "0.12.0",
|
|
33
33
|
"path": "framework/core",
|
|
34
34
|
"deps": [
|
|
35
35
|
"@uniweb/semantic-parser",
|
|
@@ -37,19 +37,19 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"@uniweb/frame-bridge": {
|
|
40
|
-
"version": "0.3.
|
|
40
|
+
"version": "0.3.1",
|
|
41
41
|
"path": "framework/frame-bridge",
|
|
42
42
|
"deps": []
|
|
43
43
|
},
|
|
44
44
|
"@uniweb/icons": {
|
|
45
|
-
"version": "0.4.
|
|
45
|
+
"version": "0.4.3",
|
|
46
46
|
"path": "framework/icons",
|
|
47
47
|
"deps": [
|
|
48
48
|
"@uniweb/core"
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"@uniweb/kit": {
|
|
52
|
-
"version": "0.13.
|
|
52
|
+
"version": "0.13.5",
|
|
53
53
|
"path": "framework/kit",
|
|
54
54
|
"deps": [
|
|
55
55
|
"@uniweb/core",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"deps": []
|
|
69
69
|
},
|
|
70
70
|
"@uniweb/projections": {
|
|
71
|
-
"version": "0.3.
|
|
71
|
+
"version": "0.3.5",
|
|
72
72
|
"path": "framework/projections",
|
|
73
73
|
"deps": [
|
|
74
74
|
"@uniweb/content-writer",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
]
|
|
77
77
|
},
|
|
78
78
|
"@uniweb/runtime": {
|
|
79
|
-
"version": "0.12.
|
|
79
|
+
"version": "0.12.10",
|
|
80
80
|
"path": "framework/runtime",
|
|
81
81
|
"deps": [
|
|
82
82
|
"@uniweb/core",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/semantic-parser": {
|
|
102
|
-
"version": "1.3.
|
|
102
|
+
"version": "1.3.1",
|
|
103
103
|
"path": "framework/semantic-parser",
|
|
104
104
|
"deps": []
|
|
105
105
|
},
|
package/src/utils/config.js
CHANGED
|
@@ -105,9 +105,20 @@ export function getRegistryApiBaseUrl() {
|
|
|
105
105
|
return fromCfg
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
return
|
|
108
|
+
return DEFAULT_BACKEND_ORIGIN
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The built-in backend origin — the last tier of the ladder, and the 98% case.
|
|
113
|
+
*
|
|
114
|
+
* Exported because `site.yml::$backend` is written ONLY when the backend is NOT this
|
|
115
|
+
* (`recordSiteBackend`, utils/site-identity.js). That test needs the *built-in* default,
|
|
116
|
+
* which `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier
|
|
117
|
+
* won, so on a machine with a session or a saved config it returns something else
|
|
118
|
+
* entirely. Two copies of the literal would silently disagree exactly there.
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
|
|
121
|
+
|
|
111
122
|
/**
|
|
112
123
|
* Read workspace package globs.
|
|
113
124
|
* Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's SYNC SCOPE — reading and asserting `site.yml::$backend`.
|
|
3
|
+
*
|
|
4
|
+
* A project that has synced holds backend-minted identity on **four** surfaces:
|
|
5
|
+
*
|
|
6
|
+
* · `site.yml::$uuid` the site-content entity
|
|
7
|
+
* · every collection record `$uuid` in its own source file (the pull-side match key)
|
|
8
|
+
* · `assets.json` local asset path → content-addressed id
|
|
9
|
+
* · `.uniweb/sync-cache.json` item uuids, hashes, base versions
|
|
10
|
+
*
|
|
11
|
+
* All four are meaningless against a different backend, and none of them says which
|
|
12
|
+
* backend it came from. `$backend` is the one fact that scopes all of them — which is
|
|
13
|
+
* why a mismatch is a **stop**, not a fallback: it does not mean "we guessed the wrong
|
|
14
|
+
* default", it means the entire stored surface is foreign.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ **`$backend` is absent for the default backend, deliberately.** The 98% case keeps a
|
|
17
|
+
* clean `site.yml`, and an absent value reads as the default — correct both for a project
|
|
18
|
+
* written before this key existed and for one synced against the default. That inference
|
|
19
|
+
* is monotone: it is never worse than the pre-`$backend` behaviour, which recorded nothing
|
|
20
|
+
* at all.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **Reads `site.yml` only, not the legacy `site.yaml`.** That matches every existing
|
|
23
|
+
* identity reader AND all three writers (`writeSiteEntityUuid`, `writeSiteOrg`,
|
|
24
|
+
* `clone.js`'s `seedYamlUuid`), so this module is consistent with what is on disk. It is
|
|
25
|
+
* also a KNOWN GAP, not an oversight: five other readers do accept `site.yaml`, and
|
|
26
|
+
* `upsertYamlScalar` creates a file when missing — so on a `site.yaml` project the writers
|
|
27
|
+
* produce a phantom `site.yml` holding nothing but identity. Closing that is its own change,
|
|
28
|
+
* and it has to move the readers and the writers together or it makes the split worse.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import yaml from 'js-yaml'
|
|
34
|
+
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
35
|
+
|
|
36
|
+
// ⛔ NO STATIC `@uniweb/build` IMPORT IN THIS FILE — it is an OPTIONAL PEER, and this
|
|
37
|
+
// module is reachable from the CLI's startup graph (`index.js` imports `clone` eagerly,
|
|
38
|
+
// and `clone` imports this). A static import here makes `uniweb --version` die with
|
|
39
|
+
// ERR_MODULE_NOT_FOUND on a global install that has no build package:
|
|
40
|
+
//
|
|
41
|
+
// $ npm i -g uniweb && uniweb --version
|
|
42
|
+
// Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@uniweb/build'
|
|
43
|
+
//
|
|
44
|
+
// `test/smoke-startup.test.js` catches this structurally — and did, on the commit that
|
|
45
|
+
// introduced this file. The writer below therefore imports lazily, which also keeps the
|
|
46
|
+
// cost off every startup that never writes. Command modules may import build statically;
|
|
47
|
+
// anything a `utils/` leaf pulls in cannot.
|
|
48
|
+
|
|
49
|
+
/** A bare origin with no trailing slash, or null when unparseable. */
|
|
50
|
+
export function normalizeOrigin(value) {
|
|
51
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
52
|
+
try {
|
|
53
|
+
return new URL(value).origin
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The project's identity trio, read from `site.yml`. Every field is independently
|
|
61
|
+
* optional — a project may be unsynced (no `uuid`), on the default backend (no
|
|
62
|
+
* `backend`), or personally owned (no `org`).
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ This is NOT yet the single accessor for `$uuid`. Nine other places still read it
|
|
65
|
+
* directly; they are correct as they stand and were deliberately left alone when the
|
|
66
|
+
* scope check was centralized here (a per-read accessor was solving a coupling problem
|
|
67
|
+
* that one guard solves better). Consolidating them is separable cleanup.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} siteDir
|
|
70
|
+
* @returns {{ uuid: string|null, backend: string|null, org: string|null }}
|
|
71
|
+
*/
|
|
72
|
+
export function readSiteIdentity(siteDir) {
|
|
73
|
+
const path = join(siteDir, 'site.yml')
|
|
74
|
+
if (!existsSync(path)) return { uuid: null, backend: null, org: null }
|
|
75
|
+
let y
|
|
76
|
+
try {
|
|
77
|
+
y = yaml.load(readFileSync(path, 'utf8'))
|
|
78
|
+
} catch {
|
|
79
|
+
// Unreadable or malformed — report "nothing recorded" rather than throwing. Every
|
|
80
|
+
// caller here is a guard or a default; none of them should be the reason a command
|
|
81
|
+
// dies, and a malformed site.yml has its own, better error elsewhere.
|
|
82
|
+
return { uuid: null, backend: null, org: null }
|
|
83
|
+
}
|
|
84
|
+
if (!y || typeof y !== 'object') return { uuid: null, backend: null, org: null }
|
|
85
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
86
|
+
return {
|
|
87
|
+
uuid: str(y.$uuid),
|
|
88
|
+
backend: normalizeOrigin(y.$backend),
|
|
89
|
+
org: str(y.$org)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The backend this project is bound to, with the default filled in.
|
|
95
|
+
*
|
|
96
|
+
* Use this rather than `readSiteIdentity().backend` wherever an absent value means the
|
|
97
|
+
* default — which is everywhere except a "was it recorded?" question.
|
|
98
|
+
*
|
|
99
|
+
* ⛔ **NOT for the origin ladder.** `resolveBackendOrigin`'s `siteScope` tier must receive
|
|
100
|
+
* the RAW `readSiteIdentity().backend`, which is null when nothing was recorded. Handing
|
|
101
|
+
* it this defaulted value would make the tier fire for EVERY project, and since it sits
|
|
102
|
+
* above the session it would shadow `uniweb login --backend <local>` on any project
|
|
103
|
+
* without `$backend` — i.e. break local development for the 98% that never record one.
|
|
104
|
+
* "Absent means the default" is the right rule for a comparison and the wrong one for a
|
|
105
|
+
* precedence chain, where absent has to mean "defer to the next tier".
|
|
106
|
+
*
|
|
107
|
+
* @param {string} siteDir
|
|
108
|
+
* @returns {string} a bare origin
|
|
109
|
+
*/
|
|
110
|
+
export function resolveSiteScope(siteDir) {
|
|
111
|
+
return readSiteIdentity(siteDir).backend || DEFAULT_BACKEND_ORIGIN
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Record the sync scope on a site that has just been created or seeded.
|
|
116
|
+
*
|
|
117
|
+
* A no-op for the default backend (see the ⛔ above) and a no-op when the value is
|
|
118
|
+
* already what we would write, so a re-push never dirties `git status`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} siteDir
|
|
121
|
+
* @param {string} origin - the backend the site was just created on
|
|
122
|
+
* @param {object} [deps]
|
|
123
|
+
* @param {() => Promise<object>} [deps.loadUwx] - injected module loader. Exists so the
|
|
124
|
+
* two failure branches below can be TESTED: both are about which `@uniweb/build`
|
|
125
|
+
* happens to be on disk, which a test cannot otherwise vary — and the too-old
|
|
126
|
+
* branch is precisely the one that used to fail silently.
|
|
127
|
+
* @returns {Promise<string|null>} the origin recorded, or null when nothing was written
|
|
128
|
+
*/
|
|
129
|
+
export async function recordSiteBackend(siteDir, origin, deps = {}) {
|
|
130
|
+
const norm = normalizeOrigin(origin)
|
|
131
|
+
if (!norm || norm === DEFAULT_BACKEND_ORIGIN) return null
|
|
132
|
+
if (readSiteIdentity(siteDir).backend === norm) return null
|
|
133
|
+
const loadUwx = deps.loadUwx || (() => import('@uniweb/build/uwx'))
|
|
134
|
+
let mod
|
|
135
|
+
try {
|
|
136
|
+
// Lazy by necessity, not by taste — see the header.
|
|
137
|
+
mod = await loadUwx()
|
|
138
|
+
} catch {
|
|
139
|
+
// No `@uniweb/build` at all. It is an OPTIONAL peer, so this is a supported
|
|
140
|
+
// configuration and not worth a word — the scope simply goes unrecorded, which is
|
|
141
|
+
// the behaviour everyone had before this key existed.
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
|
|
146
|
+
// above. `@uniweb/build` gained `writeSiteBackend` in 0.25.3; against an older copy the
|
|
147
|
+
// import SUCCEEDS and the export is `undefined`, so calling it throws a TypeError that
|
|
148
|
+
// a blanket catch would swallow — leaving the scope silently unrecorded on a project
|
|
149
|
+
// that will later be stopped by the guard and told to add `$backend` by hand. Since the
|
|
150
|
+
// CLI declares build as a peer at a caret range, a lockfile pinned to an older patch
|
|
151
|
+
// reaches exactly this state on a CLI-only upgrade. Say so once.
|
|
152
|
+
if (typeof mod.writeSiteBackend !== 'function') {
|
|
153
|
+
console.error(
|
|
154
|
+
`\x1b[33m⚠\x1b[0m This project's @uniweb/build is too old to record which backend it syncs with — upgrade it (\`npx uniweb@latest update\`), or add \`$backend: ${norm}\` to site.yml.`
|
|
155
|
+
)
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
mod.writeSiteBackend(siteDir, norm)
|
|
161
|
+
return norm
|
|
162
|
+
} catch {
|
|
163
|
+
// Same rule as `recordSiteOrg`: the uuid is the load-bearing write. Losing the scope
|
|
164
|
+
// note must never fail a create that already succeeded on the backend — the guard
|
|
165
|
+
// degrades to the pre-`$backend` behaviour, which is what everyone had until now.
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refuse to act on a site whose stored identity belongs to a different backend.
|
|
172
|
+
*
|
|
173
|
+
* ⭐ **A stop, not a warning.** `BackendClient.token()` already carries an advisory
|
|
174
|
+
* origin-mismatch guard for the SESSION (wrong bearer → a rejected request, recoverable).
|
|
175
|
+
* This is the third leg and it is categorically worse: the stored surface is foreign, so
|
|
176
|
+
* proceeding sends one backend's identity to another. The observed outcomes are a 404
|
|
177
|
+
* whose stock advice destroys the binding, and — on the record lane — a hard refusal
|
|
178
|
+
* (*"item uuids are globally unique; cross-entity move is not supported"*).
|
|
179
|
+
*
|
|
180
|
+
* Silent for an unsynced project (nothing stored yet ⇒ nothing to be foreign) and for a
|
|
181
|
+
* project on the default backend with no explicit override.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} siteDir
|
|
184
|
+
* @param {string} origin - the backend this command resolved to
|
|
185
|
+
* @returns {{ ok: true } | { ok: false, message: string, hint: string[] }}
|
|
186
|
+
*/
|
|
187
|
+
export function assertSiteBackendScope(siteDir, origin) {
|
|
188
|
+
const { uuid, backend } = readSiteIdentity(siteDir)
|
|
189
|
+
// Nothing minted here yet — a create is exactly what is supposed to happen next, and
|
|
190
|
+
// it will record the scope itself. Checking a project with no stored identity would
|
|
191
|
+
// reject the first push of every new site.
|
|
192
|
+
if (!uuid) return { ok: true }
|
|
193
|
+
|
|
194
|
+
const target = normalizeOrigin(origin)
|
|
195
|
+
const bound = backend || DEFAULT_BACKEND_ORIGIN
|
|
196
|
+
if (!target || target === bound) return { ok: true }
|
|
197
|
+
|
|
198
|
+
const hint = [
|
|
199
|
+
`site.yml::$uuid (${uuid}) was minted by ${bound}, and the record uuids, assets.json`,
|
|
200
|
+
'and .uniweb/sync-cache.json are scoped to it too. Sending them elsewhere is refused.',
|
|
201
|
+
'',
|
|
202
|
+
`To work with ${bound}: uniweb login --backend ${bound} (or pass --backend ${bound})`,
|
|
203
|
+
`To move this project to ${target}, it becomes a NEW site there — clear $uuid, $org and`,
|
|
204
|
+
'$backend from site.yml and delete .uniweb/ and assets.json first.'
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
// ⚠️ THE ONE FALSE POSITIVE, and it prints its own fix.
|
|
208
|
+
//
|
|
209
|
+
// `$backend` is omitted for the default backend, so an ABSENT value is ambiguous: it
|
|
210
|
+
// means "the default" for anything written under this scheme, and "unknown" for a
|
|
211
|
+
// project that synced to a non-default backend BEFORE the key existed. This branch
|
|
212
|
+
// reads absent as the default, so that second project is stopped and told it belongs
|
|
213
|
+
// to a backend it never used.
|
|
214
|
+
//
|
|
215
|
+
// Accepted deliberately rather than softened to a warning, because the alternative is
|
|
216
|
+
// worse in the case that matters: warning-on-absent would leave every DEFAULT-backend
|
|
217
|
+
// project — the 98% — unprotected forever, since those never record the key. Stopping
|
|
218
|
+
// on a guess is only tolerable when the guess prints the one-line correction, so it
|
|
219
|
+
// does. (The population at risk is also near-empty by construction: pre-`$backend`
|
|
220
|
+
// projects on a non-default backend, at a moment when no such backend is running.)
|
|
221
|
+
if (!backend) {
|
|
222
|
+
hint.push(
|
|
223
|
+
'',
|
|
224
|
+
`If this project actually syncs with ${target}, nothing recorded that — say so once:`,
|
|
225
|
+
` add $backend: ${target} to site.yml, and this stops asking.`
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
message: `This project's stored identity belongs to ${bound}, but this command targets ${target}.`,
|
|
232
|
+
hint
|
|
233
|
+
}
|
|
234
|
+
}
|