uniweb 0.32.1 → 0.33.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 +4 -4
- package/partials/agents.md +4 -2
- package/src/backend/site-sync.js +19 -1
- package/src/commands/i18n.js +2 -2
- package/src/framework-index.json +6 -8
- 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.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/runtime": "^0.13.
|
|
45
|
-
"@uniweb/core": "^0.13.0",
|
|
44
|
+
"@uniweb/runtime": "^0.13.1",
|
|
46
45
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
46
|
+
"@uniweb/core": "^0.13.1",
|
|
47
47
|
"@uniweb/kit": "^0.14.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.
|
|
50
|
+
"@uniweb/build": "^0.29.0",
|
|
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
|
@@ -932,14 +932,32 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
932
932
|
// wrote. Reading the live file rather than a snapshot is what makes a moved
|
|
933
933
|
// map (a teammate's push, a pull) read as changed instead of matching a copy
|
|
934
934
|
// of itself.
|
|
935
|
+
// · RECORDED — the site's own org, from `site.yml::$org`, written by the push
|
|
936
|
+
// that banked these hashes.
|
|
937
|
+
//
|
|
938
|
+
// ⛔ THE ORG IS NOT OPTIONAL HERE, AND OMITTING IT WAS SILENT. It is what resolves
|
|
939
|
+
// a foundation-relative `@/member` into the `@org/member` the push shipped and
|
|
940
|
+
// keyed its hashes by. Without it the emit does not fail — `buildCollectionEntities`
|
|
941
|
+
// WARNS and ships the model unresolved, deliberately, so an org-less export still
|
|
942
|
+
// works — so every record of a `@/`-scoped collection is emitted under a key that
|
|
943
|
+
// can never match its banked one, and reads as changed forever.
|
|
944
|
+
//
|
|
945
|
+
// ⚠️ It hides in plain sight because `@std/…` collections are unaffected: their
|
|
946
|
+
// scope is already absolute, so they match. A site mixing both — the marketing
|
|
947
|
+
// fixture has `@std/person` AND `@proximify/member` — shows some records settling
|
|
948
|
+
// and others never settling, which reads like a content problem rather than a
|
|
949
|
+
// resolution one. Measured on matinee 2026-08-29: `status` reported 4 changed
|
|
950
|
+
// immediately after a successful push; passing the org took it to 1.
|
|
935
951
|
const applied = readAppliedInjections(siteDir)
|
|
936
952
|
const assetIds = readAssetMap(siteDir)
|
|
953
|
+
const org = readSiteOrg(siteDir)
|
|
937
954
|
const pkg = await emitSyncPackages(siteDir, {
|
|
938
955
|
resolveModel: makeModelResolver({ client: null, offline: true }),
|
|
939
956
|
priorHashes,
|
|
940
957
|
sendAll,
|
|
941
958
|
...applied,
|
|
942
|
-
...(Object.keys(assetIds).length ? { assetIds } : {})
|
|
959
|
+
...(Object.keys(assetIds).length ? { assetIds } : {}),
|
|
960
|
+
...(org ? { org } : {})
|
|
943
961
|
})
|
|
944
962
|
const changed =
|
|
945
963
|
(pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
|
package/src/commands/i18n.js
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* prompts for selection.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { resolve, join, dirname, basename, relative } from 'path'
|
|
19
|
-
import { existsSync } from 'fs'
|
|
18
|
+
import { resolve, join, dirname, basename, relative } from 'node:path'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
20
|
import {
|
|
21
21
|
readFile,
|
|
22
22
|
writeFile,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-28T16:19:53.330Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.29.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -12,8 +12,6 @@
|
|
|
12
12
|
"@uniweb/projections",
|
|
13
13
|
"@uniweb/runtime",
|
|
14
14
|
"@uniweb/schemas",
|
|
15
|
-
"@uniweb/schemas",
|
|
16
|
-
"@uniweb/semantic-parser",
|
|
17
15
|
"@uniweb/semantic-parser",
|
|
18
16
|
"@uniweb/theming"
|
|
19
17
|
]
|
|
@@ -29,7 +27,7 @@
|
|
|
29
27
|
"deps": []
|
|
30
28
|
},
|
|
31
29
|
"@uniweb/core": {
|
|
32
|
-
"version": "0.13.
|
|
30
|
+
"version": "0.13.1",
|
|
33
31
|
"path": "framework/core",
|
|
34
32
|
"deps": [
|
|
35
33
|
"@uniweb/semantic-parser",
|
|
@@ -68,7 +66,7 @@
|
|
|
68
66
|
"deps": []
|
|
69
67
|
},
|
|
70
68
|
"@uniweb/projections": {
|
|
71
|
-
"version": "0.5.
|
|
69
|
+
"version": "0.5.1",
|
|
72
70
|
"path": "framework/projections",
|
|
73
71
|
"deps": [
|
|
74
72
|
"@uniweb/content-writer",
|
|
@@ -76,7 +74,7 @@
|
|
|
76
74
|
]
|
|
77
75
|
},
|
|
78
76
|
"@uniweb/runtime": {
|
|
79
|
-
"version": "0.13.
|
|
77
|
+
"version": "0.13.1",
|
|
80
78
|
"path": "framework/runtime",
|
|
81
79
|
"deps": [
|
|
82
80
|
"@uniweb/core",
|
|
@@ -104,7 +102,7 @@
|
|
|
104
102
|
"deps": []
|
|
105
103
|
},
|
|
106
104
|
"@uniweb/templates": {
|
|
107
|
-
"version": "0.
|
|
105
|
+
"version": "0.10.0",
|
|
108
106
|
"path": "framework/templates",
|
|
109
107
|
"deps": []
|
|
110
108
|
},
|
|
@@ -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(
|