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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.32.1",
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.0",
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.28.1",
50
+ "@uniweb/build": "^0.29.0",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
52
  "@uniweb/semantic-parser": "^1.3.1"
53
53
  },
@@ -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, reason } = resolveService(website, 'assistant') // or 'search', or your own
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` also returns a `reason`, and it is **not visitor copy** — 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. **Absence is a rendering decision, not a message**: 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. The `reason` string is there for *you*, while you wire a site up.
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
 
@@ -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)
@@ -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,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-27T21:57:57.427Z",
3
+ "generatedAt": "2026-08-28T16:19:53.330Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.28.1",
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.0",
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.0",
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.0",
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.9.6",
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 — loopback OAuth against the backend's /dev/auth/authorize.
427
- // The CLI hosts a one-shot 127.0.0.1 server (awaitBrowserCallback), opens the
428
- // browser to authorize, and the backend (after the Google dance) 302s back to
429
- // the loopback with the token (or an error). state is a CSRF nonce echoed back
430
- // and verified. The token never leaves browser→localhost. Gated by
431
- // BROWSER_AVAILABLE until the endpoint is live.
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
- const token = await awaitBrowserCallback({
442
- buildUrl: (redirectUri) =>
443
- `${base}/dev/auth/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`,
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 tok = params.get('token')
449
- if (!tok) return { error: 'no token returned by the callback.' }
450
- return { value: tok }
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 (120s)…',
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
- let account = null
459
- try {
460
- account = await fetchMe({ apiBase, token })
461
- } catch {
462
- /* identity optional; token is valid */
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
- const record = { token, origin: normOrigin(apiBase) }
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
- if (BROWSER_AVAILABLE)
553
- choices.push({
554
- title: 'Browser / social (Google, etc.)',
555
- value: 'browser'
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(