uniweb 0.32.2 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/partials/agents.md +4 -2
- package/src/backend/site-sync.js +153 -13
- package/src/commands/pull.js +35 -1
- package/src/commands/push.js +4 -0
- package/src/framework-index.json +4 -4
- package/src/utils/registry-auth.js +73 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "^0.13.1",
|
|
45
45
|
"@uniweb/kit": "^0.14.0",
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
47
|
+
"@uniweb/runtime": "^0.13.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.
|
|
50
|
+
"@uniweb/build": "^0.29.1",
|
|
51
51
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
52
|
"@uniweb/semantic-parser": "^1.3.1"
|
|
53
53
|
},
|
package/partials/agents.md
CHANGED
|
@@ -1851,7 +1851,7 @@ by the same rule — your declaration, then the host's, then neither:
|
|
|
1851
1851
|
```jsx
|
|
1852
1852
|
import { resolveService } from '@uniweb/kit'
|
|
1853
1853
|
|
|
1854
|
-
const { url,
|
|
1854
|
+
const { url, source } = resolveService(website, 'assistant') // or 'search', or your own
|
|
1855
1855
|
```
|
|
1856
1856
|
|
|
1857
1857
|
**The name is open**: the framework ships clients for what it implements and
|
|
@@ -1870,7 +1870,9 @@ if (!url) return null // this site has no agent — render nothing, or
|
|
|
1870
1870
|
|
|
1871
1871
|
⛔ **Absent is the answer, not a lookup that failed.** No `url` means the site has no agent — never enabled, or this host runs none. Render for that case; don't retry it. And don't hardcode `/_agent/chat` when nothing was declared: on a static host that turns "no agent here" into a 404 your component can't tell from a broken endpoint. The path is named above so you recognize the shape, not so you can construct it.
|
|
1872
1872
|
|
|
1873
|
-
⛔ **And don't tell the visitor.** `resolveService`
|
|
1873
|
+
⛔ **And don't tell the visitor.** `resolveService` returns `{ url, source }` and **deliberately nothing else** — there is no explanatory string and there was one, removed because it was a mistake. A visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. Worse, it is unfixably the wrong language: sites here are multilingual, or unilingual and not English, and a canned constant bypasses the site's whole localization pipeline. **Absence is a rendering decision, not a message**, and a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. Any text a visitor should read is *site content* — authored and localized.
|
|
1874
|
+
|
|
1875
|
+
`source` is `'site'`, `'host'` or `null`, and it is a **diagnostic for you** while you wire a site up: it says which tier answered, which is the thing to check when a host's value appears not to be taking effect. `'host'` with a null `url` means the host answered and offered no address.
|
|
1874
1876
|
|
|
1875
1877
|
*(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
|
|
1876
1878
|
|
package/src/backend/site-sync.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
computeUnitHashes,
|
|
28
28
|
collectUnitUuids,
|
|
29
29
|
collectFolderItemUuids,
|
|
30
|
+
collectCollectionUuids,
|
|
30
31
|
readAssetMap
|
|
31
32
|
} from '@uniweb/build/uwx'
|
|
32
33
|
|
|
@@ -241,6 +242,7 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
241
242
|
const prior = readSyncCacheFile(siteDir)
|
|
242
243
|
const dropped = [
|
|
243
244
|
'itemUuids',
|
|
245
|
+
'collectionUuids',
|
|
244
246
|
'hashes',
|
|
245
247
|
'baseVersions',
|
|
246
248
|
'unitBases',
|
|
@@ -248,6 +250,9 @@ export function clearRemoteSyncState(siteDir, siteUuid = null) {
|
|
|
248
250
|
].filter((k) => prior[k] && Object.keys(prior[k]).length)
|
|
249
251
|
updateSyncCache(siteDir, {
|
|
250
252
|
itemUuids: {},
|
|
253
|
+
// Remote-derived exactly like itemUuids — it holds the OLD site's collection
|
|
254
|
+
// ids, and surviving the drop it would offer them for the new site's sections.
|
|
255
|
+
collectionUuids: {},
|
|
251
256
|
hashes: {},
|
|
252
257
|
baseVersions: {},
|
|
253
258
|
unitBases: {},
|
|
@@ -468,6 +473,32 @@ export function readItemUuids(siteDir) {
|
|
|
468
473
|
export function readFolderItemUuids(siteDir) {
|
|
469
474
|
return readMap(siteDir, 'folderItemUuids')
|
|
470
475
|
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Collection-declaration identity: `{ <collection name>: <backend $uuid> }`.
|
|
479
|
+
*
|
|
480
|
+
* ⛔ THE THIRD MAP, AND EACH ONE EXISTS FOR THE SAME REASON. `itemUuids` is keyed by
|
|
481
|
+
* the file an item projects to. Two kinds of item have no file of their own — a
|
|
482
|
+
* folder's placements, and a collection DECLARATION (they all live in one
|
|
483
|
+
* `collections/collections.yml`) — so a path-keyed map has no shape either could
|
|
484
|
+
* occupy, and a push re-sends that whole section uuid-less.
|
|
485
|
+
*
|
|
486
|
+
* The backend refuses an all-blank section over stored items rather than applying it,
|
|
487
|
+
* because applying it would insert every record fresh and delete every stored row —
|
|
488
|
+
* content survives, identity does not. So `push` worked once and every push after was
|
|
489
|
+
* refused. Measured 2026-08-29; collab framework-backend-812b.
|
|
490
|
+
*
|
|
491
|
+
* ⭐ Keyed by NAME, which the backend enforces unique within the section and uses as
|
|
492
|
+
* its own join key. ⛔ Not `$id`: it holds the same string but is a payload-local
|
|
493
|
+
* handle the backend skips on parse and never stores.
|
|
494
|
+
*/
|
|
495
|
+
export function readCollectionUuids(siteDir) {
|
|
496
|
+
return readMap(siteDir, 'collectionUuids')
|
|
497
|
+
}
|
|
498
|
+
export function writeCollectionUuids(siteDir, map) {
|
|
499
|
+
if (!map || !Object.keys(map).length) return
|
|
500
|
+
updateSyncCache(siteDir, { collectionUuids: map })
|
|
501
|
+
}
|
|
471
502
|
export function writeFolderItemUuids(siteDir, map) {
|
|
472
503
|
if (!map || !Object.keys(map).length) return
|
|
473
504
|
updateSyncCache(siteDir, { folderItemUuids: map })
|
|
@@ -932,18 +963,88 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
932
963
|
// wrote. Reading the live file rather than a snapshot is what makes a moved
|
|
933
964
|
// map (a teammate's push, a pull) read as changed instead of matching a copy
|
|
934
965
|
// of itself.
|
|
966
|
+
// · RECORDED — the site's own org, from `site.yml::$org`, written by the push
|
|
967
|
+
// that banked these hashes.
|
|
968
|
+
//
|
|
969
|
+
// ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
|
|
970
|
+
// a foundation-relative `@/member` into the `@org/member` the push shipped and
|
|
971
|
+
// keyed its hashes by. Without it the emit does not fail — `buildCollectionEntities`
|
|
972
|
+
// WARNS and ships the model unresolved, deliberately, so an org-less export still
|
|
973
|
+
// works — so every record of a `@/`-scoped collection is emitted under a key that
|
|
974
|
+
// can never match its banked one, and reads as changed forever.
|
|
975
|
+
//
|
|
976
|
+
// ⚠️ It hides in plain sight because `@std/…` collections are unaffected: their
|
|
977
|
+
// scope is already absolute, so they match. A site mixing both — the marketing
|
|
978
|
+
// fixture has `@std/person` AND `@proximify/member` — shows some records settling
|
|
979
|
+
// and others never settling, which reads like a content problem rather than a
|
|
980
|
+
// resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
|
|
981
|
+
// immediately after a successful push; passing the org took it to 1.
|
|
982
|
+
const pkg = await comparisonEmit(siteDir, { priorHashes, sendAll })
|
|
983
|
+
const changed =
|
|
984
|
+
(pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
|
|
985
|
+
return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The emit whose hashes are comparable with the banked ones — ONE definition.
|
|
990
|
+
*
|
|
991
|
+
* ⛔ EVERY DEFECT IN THIS AREA HAS BEEN A WRITER AND A READER DISAGREEING ABOUT
|
|
992
|
+
* WHICH DOCUMENT A HASH DESCRIBES, so the option set that makes them agree must
|
|
993
|
+
* have a single home. Assembled from three sources, each for a stated reason:
|
|
994
|
+
*
|
|
995
|
+
* · BANKED the injections the push applied before hashing — serve URLs and
|
|
996
|
+
* the pinned foundation ref, which only a round trip produces.
|
|
997
|
+
* · RE-DERIVED asset identity, from the COMMITTED `assets.json`, so a moved map
|
|
998
|
+
* reads as changed rather than matching a copy of itself.
|
|
999
|
+
* · RECORDED the site's org, which resolves a foundation-relative `@/x` into
|
|
1000
|
+
* the `@org/x` the push keyed its hashes by, and the collection
|
|
1001
|
+
* identity a push stamps — so `status` hashes the same document a
|
|
1002
|
+
* push would send rather than one missing a section's `$uuid`s.
|
|
1003
|
+
*
|
|
1004
|
+
* Offline by design — measured at zero HTTP requests, a property the cross-client
|
|
1005
|
+
* flows rely on.
|
|
1006
|
+
*/
|
|
1007
|
+
async function comparisonEmit(siteDir, { priorHashes = {}, sendAll = false } = {}) {
|
|
935
1008
|
const applied = readAppliedInjections(siteDir)
|
|
936
1009
|
const assetIds = readAssetMap(siteDir)
|
|
937
|
-
const
|
|
1010
|
+
const org = readSiteOrg(siteDir)
|
|
1011
|
+
const collectionUuids = readCollectionUuids(siteDir)
|
|
1012
|
+
return emitSyncPackages(siteDir, {
|
|
938
1013
|
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
939
1014
|
priorHashes,
|
|
940
1015
|
sendAll,
|
|
941
1016
|
...applied,
|
|
942
|
-
...(Object.keys(
|
|
1017
|
+
...(Object.keys(collectionUuids).length ? { collectionUuids } : {}),
|
|
1018
|
+
...(Object.keys(assetIds).length ? { assetIds } : {}),
|
|
1019
|
+
...(org ? { org } : {})
|
|
943
1020
|
})
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Re-bank the send-only-changed hashes over the files as they NOW stand.
|
|
1025
|
+
*
|
|
1026
|
+
* ⛔ FOR A WRITER THAT REWRITES THE WORKING TREE — today, `uniweb pull`.
|
|
1027
|
+
*
|
|
1028
|
+
* A pull projects the backend's document into source files, and that projection is
|
|
1029
|
+
* canonical rather than byte-identical to what was there: it moves section ordering
|
|
1030
|
+
* out of filename prefixes (`1-hero.md` → `hero.md` plus an explicit `sections:`
|
|
1031
|
+
* list) and stamps each section's `id`. Lossless, and a different document.
|
|
1032
|
+
*
|
|
1033
|
+
* ⚠️ `pull` already knew this for the OTHER map — it clears the `local` unit base
|
|
1034
|
+
* because "what we would emit from them is not byte-identical to it, so the old
|
|
1035
|
+
* local base no longer describes anything". That reasoning was never carried to the
|
|
1036
|
+
* hashes, which were left STALE rather than unknown: the next `uniweb status`
|
|
1037
|
+
* reported the site as having unpushed content immediately after a pull, forever.
|
|
1038
|
+
* Measured on matinee 2026-08-29 — `push → pull` reported 1 changed of 8.
|
|
1039
|
+
*
|
|
1040
|
+
* ⭐ Re-banking is the correct answer rather than clearing, because after a pull the
|
|
1041
|
+
* on-disk state IS the agreed state: it came from the backend. A push with no edits
|
|
1042
|
+
* in between should send nothing, and clearing would make it send everything.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function rebankSyncHashes(siteDir) {
|
|
1045
|
+
const pkg = await comparisonEmit(siteDir, { sendAll: true })
|
|
1046
|
+
writeSyncCache(siteDir, pkg.hashes || {}, pkg.applied || {})
|
|
1047
|
+
return Object.keys(pkg.hashes || {}).length
|
|
947
1048
|
}
|
|
948
1049
|
|
|
949
1050
|
/**
|
|
@@ -1045,18 +1146,47 @@ export async function pushSyncPackages({
|
|
|
1045
1146
|
return null
|
|
1046
1147
|
}
|
|
1047
1148
|
if (problem?.reason === 'identity_required') {
|
|
1149
|
+
// ⛔ THE REFUSAL IS PER-SECTION, NOT PER-PUSH, AND WE USED TO SAY OTHERWISE.
|
|
1150
|
+
//
|
|
1151
|
+
// The backend asks, of each section independently: is this section's ENTIRE
|
|
1152
|
+
// incoming record set uuid-less while the stored entity already has items in
|
|
1153
|
+
// it? So a push can carry correct identity for most of the document and still
|
|
1154
|
+
// be refused over one section it stamps none of — and the old wording ("this
|
|
1155
|
+
// copy has no record of the site's item identity", "the recovery … could not
|
|
1156
|
+
// reach the backend", "`uniweb pull` also restores it") was wrong on all
|
|
1157
|
+
// three counts in exactly that case: the copy has identity, no recovery was
|
|
1158
|
+
// attempted, and a pull changes nothing.
|
|
1159
|
+
//
|
|
1160
|
+
// ⭐ THE BACKEND ALREADY SENDS WHAT LOCATES IT — `section_id`,
|
|
1161
|
+
// `records_without_uuid`, `stored_items` — and this branch discarded every
|
|
1162
|
+
// one of them, so every refusal anyone collected was missing the only fields
|
|
1163
|
+
// that say WHERE. (Named by backend in collab `framework-backend-812b`,
|
|
1164
|
+
// 2026-08-28: "the offending section and both counts have been in the body of
|
|
1165
|
+
// every refusal you have collected".)
|
|
1166
|
+
const n = problem.records_without_uuid
|
|
1167
|
+
const stored = problem.stored_items
|
|
1168
|
+
// `section_name` landed backend-side 2026-08-28 (`90e7cd7e`), replacing an
|
|
1169
|
+
// i64 row id we could not resolve. The fallback stays for an older backend,
|
|
1170
|
+
// not because the swap is pending — it is done.
|
|
1171
|
+
const where = problem.section_name ?? problem.section_id
|
|
1048
1172
|
error(
|
|
1049
|
-
`${label} push refused —
|
|
1050
|
-
|
|
1051
|
-
note(
|
|
1052
|
-
'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
|
|
1053
|
-
)
|
|
1054
|
-
note(
|
|
1055
|
-
'The recovery normally runs automatically, so it likely could not reach the backend.'
|
|
1173
|
+
`${label} push refused — one section carries no item identity` +
|
|
1174
|
+
(where === undefined ? '.' : ` (section ${where}).`)
|
|
1056
1175
|
)
|
|
1176
|
+
if (Number.isInteger(n) && Number.isInteger(stored)) {
|
|
1177
|
+
note(
|
|
1178
|
+
`That section sent ${n} record(s), none carrying a \`$uuid\`, while ${stored} item(s) are already stored there.`
|
|
1179
|
+
)
|
|
1180
|
+
}
|
|
1057
1181
|
note(
|
|
1058
|
-
'
|
|
1182
|
+
'Nothing was written. Applying it would have replaced the identity of every stored item in that section.'
|
|
1059
1183
|
)
|
|
1184
|
+
// ⚠️ Deliberately NOT "run `uniweb pull`". A pull re-harvests identity from
|
|
1185
|
+
// the backend's own document, so it fixes a LOST cache — and does nothing at
|
|
1186
|
+
// all when the cache is intact and one section simply has no entry in it,
|
|
1187
|
+
// which is the case this message now names. Suggesting it there sends the
|
|
1188
|
+
// user to re-fetch a map they already have.
|
|
1189
|
+
if (problem.detail) note(String(problem.detail))
|
|
1060
1190
|
return null
|
|
1061
1191
|
}
|
|
1062
1192
|
if (problem?.reason === 'stale_base') {
|
|
@@ -1246,6 +1376,16 @@ export async function pushSyncPackages({
|
|
|
1246
1376
|
}
|
|
1247
1377
|
harvest(finalized)
|
|
1248
1378
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
1379
|
+
// ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
|
|
1380
|
+
// placements below. These items have no file to back-fill into — they all
|
|
1381
|
+
// come from one `collections/collections.yml` — so the only place their
|
|
1382
|
+
// `$uuid` can live is the cache, keyed by the name the backend enforces
|
|
1383
|
+
// unique. Without it every push after the first re-sends the whole
|
|
1384
|
+
// `collections` section uuid-less and is refused.
|
|
1385
|
+
if (siteFinalizedDoc) {
|
|
1386
|
+
const collectionIds = collectCollectionUuids(siteFinalizedDoc)
|
|
1387
|
+
if (Object.keys(collectionIds).length) writeCollectionUuids(siteDir, collectionIds)
|
|
1388
|
+
}
|
|
1249
1389
|
finalizedTotal += finalized.length
|
|
1250
1390
|
} else {
|
|
1251
1391
|
const payload = await postLane('site-content', () =>
|
package/src/commands/pull.js
CHANGED
|
@@ -75,7 +75,8 @@ import {
|
|
|
75
75
|
resolveCollectionsConfig,
|
|
76
76
|
readZip,
|
|
77
77
|
computeUnitHashes,
|
|
78
|
-
collectUnitUuids
|
|
78
|
+
collectUnitUuids,
|
|
79
|
+
collectCollectionUuids
|
|
79
80
|
} from '@uniweb/build/uwx'
|
|
80
81
|
import {
|
|
81
82
|
readWritten,
|
|
@@ -84,6 +85,8 @@ import {
|
|
|
84
85
|
} from '../utils/pull-written.js'
|
|
85
86
|
import {
|
|
86
87
|
makeModelResolver,
|
|
88
|
+
rebankSyncHashes,
|
|
89
|
+
writeCollectionUuids,
|
|
87
90
|
mergeBaseVersions,
|
|
88
91
|
mergeItemBaseVersions,
|
|
89
92
|
writeUnitBases,
|
|
@@ -686,6 +689,10 @@ export async function pull(args = [], deps = {}) {
|
|
|
686
689
|
// Per-item identity for the next push. Without it the backend reads our
|
|
687
690
|
// records as new and re-mints every page and section row.
|
|
688
691
|
writeItemUuids(siteDir, collectUnitUuids(siteDoc))
|
|
692
|
+
// The collections section's identity has no file to live in either — same
|
|
693
|
+
// reason, same remedy, keyed by name. A pull is the other route by which a
|
|
694
|
+
// copy can recover it (see readCollectionUuids).
|
|
695
|
+
writeCollectionUuids(siteDir, collectCollectionUuids(siteDoc))
|
|
689
696
|
// Bring the media down BEFORE projecting: a newly-landed asset gains a map
|
|
690
697
|
// entry, and the projection reads that map to put authored paths back. Run
|
|
691
698
|
// after, and this pull's new assets would project as URLs and only restore
|
|
@@ -840,6 +847,33 @@ export async function pull(args = [], deps = {}) {
|
|
|
840
847
|
removed
|
|
841
848
|
)
|
|
842
849
|
|
|
850
|
+
// ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT WE JUST WROTE.
|
|
851
|
+
//
|
|
852
|
+
// The projection above is canonical, not byte-identical to what was on disk: it
|
|
853
|
+
// moves section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
|
|
854
|
+
// explicit `sections:` list) and stamps each section's `id`. Lossless, and a
|
|
855
|
+
// different document — which is exactly why the `local` unit base is cleared above.
|
|
856
|
+
//
|
|
857
|
+
// ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
|
|
858
|
+
// rather than unknown, and `uniweb status` reported unpushed content immediately
|
|
859
|
+
// after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
|
|
860
|
+
// 1 changed of 8, with nothing edited in between.
|
|
861
|
+
//
|
|
862
|
+
// Re-banking rather than clearing, because after a pull the on-disk state IS the
|
|
863
|
+
// agreed state — it came from the backend, so a push with no edits should send
|
|
864
|
+
// nothing. Clearing would make it send everything.
|
|
865
|
+
//
|
|
866
|
+
// Best-effort: a failure here costs an unnecessary re-send on the next push, never
|
|
867
|
+
// wrong content, and must not fail a pull whose files are already written.
|
|
868
|
+
if (!dryRun) {
|
|
869
|
+
try {
|
|
870
|
+
await rebankSyncHashes(siteDir)
|
|
871
|
+
} catch (err) {
|
|
872
|
+
note(`! could not re-bank the sync cache: ${err.message}`)
|
|
873
|
+
note(' The next push will re-send content that is already current.')
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
843
877
|
success(
|
|
844
878
|
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
|
|
845
879
|
(deleted ? `, ${deleted} deleted` : '')
|
package/src/commands/push.js
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
readItemBaseVersions,
|
|
86
86
|
readItemUuids,
|
|
87
87
|
readFolderItemUuids,
|
|
88
|
+
readCollectionUuids,
|
|
88
89
|
ensureItemUuids,
|
|
89
90
|
ensureSiteExists,
|
|
90
91
|
clearRemoteSyncStateIfUnbound,
|
|
@@ -474,6 +475,9 @@ export async function push(args = [], deps = {}) {
|
|
|
474
475
|
pkg = await emitSyncPackages(siteDir, {
|
|
475
476
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
476
477
|
folderItemUuids: readFolderItemUuids(siteDir),
|
|
478
|
+
// Identity for the `collections` section — see readCollectionUuids. Keyed by
|
|
479
|
+
// name, because a declaration has no file for a path-keyed map to hold.
|
|
480
|
+
collectionUuids: readCollectionUuids(siteDir),
|
|
477
481
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
478
482
|
...(asOrg ? { org: asOrg } : {}),
|
|
479
483
|
...(foundationDir ? { foundationDir } : {}),
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-28T23:49:23.885Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.29.1",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"deps": []
|
|
103
103
|
},
|
|
104
104
|
"@uniweb/templates": {
|
|
105
|
-
"version": "0.
|
|
105
|
+
"version": "0.10.0",
|
|
106
106
|
"path": "framework/templates",
|
|
107
107
|
"deps": []
|
|
108
108
|
},
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"deps": []
|
|
113
113
|
},
|
|
114
114
|
"@uniweb/unipress": {
|
|
115
|
-
"version": "0.8.
|
|
115
|
+
"version": "0.8.16",
|
|
116
116
|
"path": "framework/unipress",
|
|
117
117
|
"deps": [
|
|
118
118
|
"@uniweb/build",
|
|
@@ -225,11 +225,6 @@ export async function ensureRegistryAuth({
|
|
|
225
225
|
return record.token
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// The browser/OAuth flow is wired below (loginViaBrowser: a loopback redirect
|
|
229
|
-
// against the backend's /dev/auth/cli/authorize, token-in-redirect). Kept
|
|
230
|
-
// gated until that endpoint is live on the backend — flip to true then, and the
|
|
231
|
-
// picker offers Browser/social as the default (and `--browser` works).
|
|
232
|
-
const BROWSER_AVAILABLE = false
|
|
233
228
|
|
|
234
229
|
/**
|
|
235
230
|
* GET /dev/auth/me with a bearer → the account object ({ uuid, username,
|
|
@@ -423,45 +418,90 @@ export async function awaitBrowserCallback({
|
|
|
423
418
|
return result.value
|
|
424
419
|
}
|
|
425
420
|
|
|
426
|
-
// Browser / social —
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
421
|
+
// Browser / social — the backend's CLI delegation flow. The CLI never speaks to
|
|
422
|
+
// an identity provider and holds no client id, secret or provider knowledge: it
|
|
423
|
+
// opens ONE url (the backend's), catches a one-time code on a loopback, and
|
|
424
|
+
// trades that code for a bearer. Whatever methods the backend's own sign-in page
|
|
425
|
+
// offers — password, Google, Microsoft, anything added later — the CLI gains with
|
|
426
|
+
// no change here, because it never learns which one was used.
|
|
427
|
+
//
|
|
428
|
+
// Three legs, all verified against a live backend rather than read:
|
|
429
|
+
//
|
|
430
|
+
// GET {base}/dev/auth/authorize?callback=<loopback>&state=<nonce>
|
|
431
|
+
// no session → 302 {hub}/login?returnTo=… (the ordinary sign-in page)
|
|
432
|
+
// session → 302 <callback>?state=<ours>&code=<one-time>
|
|
433
|
+
// POST {base}/dev/auth/token {code} → { token, expires_at, account }
|
|
434
|
+
//
|
|
435
|
+
// ⛔ `callback` IS THE PARAMETER NAME, not `redirect_uri` — the backend serves
|
|
436
|
+
// this route and rejects the other spelling with a 400. ⛔ AND THE CALLBACK
|
|
437
|
+
// CARRIES A `code`, NEVER A TOKEN: the bearer is born on the POST, server-to-CLI,
|
|
438
|
+
// so it never touches the browser, the URL bar, history or a proxy log. Both were
|
|
439
|
+
// wrong here for three months — this code was written against an anticipated
|
|
440
|
+
// shape five days before the backend shipped, and being gated meant nothing could
|
|
441
|
+
// contradict it.
|
|
442
|
+
//
|
|
443
|
+
// ⛔ The loopback MUST be a v4 literal. `awaitBrowserCallback` binds 127.0.0.1
|
|
444
|
+
// explicitly and composes `http://127.0.0.1:<port>/callback`; the backend's
|
|
445
|
+
// validator accepts `http://` only, host exactly `127.0.0.1` or `localhost`, and
|
|
446
|
+
// refuses `[::1]` and any `user@host` (that last guard stops
|
|
447
|
+
// `http://127.0.0.1:1@evil.com/cb` from walking off with the code). Do not
|
|
448
|
+
// "modernise" the bind to `::`.
|
|
432
449
|
async function loginViaBrowser({ apiBase }) {
|
|
433
|
-
if (!BROWSER_AVAILABLE) {
|
|
434
|
-
throw new Error(
|
|
435
|
-
'browser/social login for the new backend isn’t available yet — use --password or --token-paste.'
|
|
436
|
-
)
|
|
437
|
-
}
|
|
438
450
|
const base = apiBase.replace(/\/$/, '')
|
|
439
451
|
const state = randomBytes(16).toString('hex')
|
|
440
452
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
453
|
+
// Leg 1+2 — open the backend's authorize url, catch the one-time code.
|
|
454
|
+
const code = await awaitBrowserCallback({
|
|
455
|
+
buildUrl: (callback) =>
|
|
456
|
+
`${base}/dev/auth/authorize?callback=${encodeURIComponent(callback)}&state=${state}`,
|
|
444
457
|
validate: (params) => {
|
|
445
458
|
if (params.get('error')) return { error: params.get('error') }
|
|
459
|
+
// Check `state` BEFORE reading anything else: it is the only thing that
|
|
460
|
+
// ties this callback to the request we made.
|
|
446
461
|
if (params.get('state') !== state)
|
|
447
462
|
return { error: 'state mismatch — please try again.' }
|
|
448
|
-
const
|
|
449
|
-
if (!
|
|
450
|
-
return { value:
|
|
463
|
+
const code = params.get('code')
|
|
464
|
+
if (!code) return { error: 'no code returned by the callback.' }
|
|
465
|
+
return { value: code }
|
|
451
466
|
},
|
|
452
467
|
openingLabel: 'Opening your browser to sign in…',
|
|
453
|
-
waitingLabel: 'Waiting for sign-in to complete (
|
|
468
|
+
waitingLabel: 'Waiting for sign-in to complete (5 min)…',
|
|
469
|
+
// A person is signing in at an identity provider, not clicking one button:
|
|
470
|
+
// a first-time Google or Microsoft login can carry a consent screen, an
|
|
471
|
+
// account chooser and 2FA. The default 120s expires mid-flow and reports a
|
|
472
|
+
// TIMEOUT, which reads as "the CLI is broken" rather than "you were slow".
|
|
473
|
+
// Same reasoning, and the same value, as the publish payment handoff.
|
|
474
|
+
timeoutMs: 5 * 60 * 1000,
|
|
454
475
|
okTitle: 'Login successful',
|
|
455
476
|
errTitle: 'Login failed'
|
|
456
477
|
})
|
|
457
478
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
479
|
+
// Leg 3 — trade the code for a bearer. Anonymous: the code IS the credential,
|
|
480
|
+
// and it is single-use, so a replay gets a 400 rather than a second session.
|
|
481
|
+
const res = await fetch(`${base}/dev/auth/token`, {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: { 'Content-Type': 'application/json' },
|
|
484
|
+
body: JSON.stringify({ code })
|
|
485
|
+
})
|
|
486
|
+
const payload = await res.json().catch(() => null)
|
|
487
|
+
if (!res.ok || !payload?.token) {
|
|
488
|
+
const detail = payload?.detail || payload?.title || `HTTP ${res.status}`
|
|
489
|
+
throw new Error(`could not complete sign-in: ${detail}`)
|
|
463
490
|
}
|
|
464
|
-
|
|
491
|
+
|
|
492
|
+
// The token response already carries the account, so no second round trip.
|
|
493
|
+
// `fetchMe` remains the fallback for a backend that answers without one.
|
|
494
|
+
let account = payload.account || null
|
|
495
|
+
if (!account) {
|
|
496
|
+
try {
|
|
497
|
+
account = await fetchMe({ apiBase, token: payload.token })
|
|
498
|
+
} catch {
|
|
499
|
+
/* identity is optional; the bearer is valid either way */
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const record = { token: payload.token, origin: normOrigin(apiBase) }
|
|
504
|
+
if (payload.expires_at) record.expiresAt = payload.expires_at
|
|
465
505
|
if (account?.uuid) record.uuid = account.uuid
|
|
466
506
|
if (account?.username) record.username = account.username
|
|
467
507
|
if (account?.handle) record.handle = account.handle
|
|
@@ -549,11 +589,10 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
549
589
|
} else {
|
|
550
590
|
const prompts = (await import('prompts')).default
|
|
551
591
|
const choices = []
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
})
|
|
592
|
+
choices.push({
|
|
593
|
+
title: 'Browser / social (Google, Microsoft, …)',
|
|
594
|
+
value: 'browser'
|
|
595
|
+
})
|
|
557
596
|
choices.push({ title: 'Username and password', value: 'password' })
|
|
558
597
|
choices.push({ title: 'Paste a token', value: 'token-paste' })
|
|
559
598
|
const { picked } = await prompts(
|