uniweb 0.34.0 → 0.34.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
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/runtime": "^0.13.3",
44
45
  "@uniweb/kit": "^0.15.0",
45
- "@uniweb/runtime": "^0.13.2",
46
- "@uniweb/core": "^0.14.0",
46
+ "@uniweb/core": "^0.14.1",
47
47
  "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.30.0",
50
+ "@uniweb/content-reader": "^1.2.4",
51
51
  "@uniweb/semantic-parser": "^1.3.1",
52
- "@uniweb/content-reader": "^1.2.4"
52
+ "@uniweb/build": "^0.30.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -105,34 +105,39 @@ export function resolveBackendOrigin(flag, { siteScope, siteBackend } = {}) {
105
105
  }
106
106
 
107
107
  /**
108
- * The fallback capability doc when `GET /dev/config` is absent or unreachable
109
- * (an older backend, or no backend at all). Keeps the client non-breaking: the
110
- * bases mirror a self-serve dev backend.
108
+ * The fallback capability doc for when `GET /dev/config` was not asked for (no
109
+ * credential in hand) or did not answer. Keeps the client non-breaking.
110
+ *
111
+ * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
112
+ * that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
113
+ * unknown reads falsy, and the caller stays silent rather than claiming a deployment
114
+ * does or does not charge. Every other key that used to sit here had no reader.
115
+ *
116
+ * ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
117
+ * a reader waiting to happen, and it is how this file came to describe a client that
118
+ * discovered its backend's gateway base, asset base and login path — none of which was
119
+ * ever true. The removals, and why each was not merely unused but wrong:
120
+ *
121
+ * `gatewayBase` sat here UNREAD until 2026-07-29. A serve location is read from the
122
+ * response that carries it (an upload plan's `serve_base`, an asset
123
+ * entry's `serve_url`, a payload's `config.base`) — never from a
124
+ * handshake, which cannot know a per-response answer.
125
+ * `assetBase` until 2026-08-17: one production host, hardcoded, applied to every
126
+ * deployment the CLI can be pointed at. Read only to compose an asset
127
+ * URL the plan already returns verbatim. Reader and composer both gone.
128
+ * `runtime` until 2026-08-22. A backend does not hold runtimes — a version comes
129
+ * from a CDN — so there is no installed set to report. What a site gets
130
+ * follows from its foundation's floor (`info.runtime`, at register).
131
+ * `auth` `loginPath` was never read: the login path is a constant in
132
+ * `utils/registry-auth.js`, and the ORIGIN comes from the resolution
133
+ * ladder, so the CLI is never told where to log in — it is born knowing.
134
+ * `delivery` `deploy` and `broker` had no reader. `publish` had one, but it could
135
+ * never refuse: the backend sent a literal true for every deployment,
136
+ * so the gate read a constant. Removed on both sides 2026-08-30.
137
+ * `assets` `supported` had no reader; the asset lane reports its own capability
138
+ * through the upload plan it returns.
111
139
  */
112
- export const DISCOVERY_DEFAULTS = {
113
- // ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
114
- // discovery or from a per-response field (an upload plan's `serve_base`, an
115
- // asset entry's `serve_url`); nothing here reconstructs one, so a default is a
116
- // route name with no consumer. (A `gatewayBase` entry lived here unread until
117
- // 2026-07-29.)
118
- //
119
- // `assetBase: 'https://assets.uniweb.app/'` sat here until 2026-08-17 — one
120
- // production host, hardcoded, applied to EVERY deployment the CLI can be
121
- // pointed at. It was only ever read to compose an asset URL, which the plan
122
- // already returns as `serve_url`; both the reader and the composer are gone.
123
- //
124
- // No `runtime` entry, and there must not be one. A backend does not hold
125
- // runtimes: a version is acquired from a CDN — the official mirror, the
126
- // distribution channel, or a local server — so there is no installed set for
127
- // it to report and nothing here to default. `runtime.installed` lived here
128
- // until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
129
- // builds to a backend; both are gone. The runtime a site gets follows from
130
- // its foundation's floor (`info.runtime`, stated at register), not from
131
- // anything the CLI asks a backend about.
132
- auth: { loginPath: '/dev/auth/login', required: true },
133
- delivery: { deploy: true, publish: true, broker: 'self-serve' },
134
- assets: { supported: false }
135
- }
140
+ export const DISCOVERY_DEFAULTS = {}
136
141
 
137
142
  export class BackendClient {
138
143
  /**
@@ -250,18 +255,78 @@ export class BackendClient {
250
255
  // ── Discovery ─────────────────────────────────────────────────────────────────
251
256
 
252
257
  /**
253
- * GET /dev/config the anonymous capability/handshake document. The one route
254
- * that answers before login (`auth: false`). Lazy + cached for the client's
255
- * lifetime; a missing route or any transport/parse error falls back to
256
- * DISCOVERY_DEFAULTS (non-breaking an older backend still works). Lets the
257
- * CLI hardcode nothing about a backend but its origin and discover the rest:
258
- * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?).
258
+ * The session bearer IF one can be had without asking — an explicit `--token`, an
259
+ * env var, or a stored unexpired session. Never prompts, never logs in, returns null
260
+ * instead. `token()` is the one that may block; this is for calls that want to be
261
+ * authenticated when possible but must not *cause* an authentication.
262
+ * @returns {Promise<string|null>}
263
+ */
264
+ async _tokenIfAvailable() {
265
+ if (this._token) return this._token
266
+ // ⛔ The injected resolver must be honoured here too, not just in `token()`.
267
+ // `pull` and `clone` pass one (`deps.getToken`), so skipping it would treat a caller
268
+ // that supplies its own auth as unauthenticated — and, worse, fall through to the
269
+ // machine's stored session, quietly using a DIFFERENT credential than the caller
270
+ // asked for. A throwing resolver is "no token", never a failed command.
271
+ if (this._getToken) {
272
+ try {
273
+ return (await this._getToken()) || null
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+ try {
279
+ const stored = await readRegistryAuth()
280
+ if (stored?.token && !isExpired(stored)) return stored.token
281
+ } catch {
282
+ /* advisory — a missing or unreadable session is simply "no token" */
283
+ }
284
+ return null
285
+ }
286
+
287
+ /**
288
+ * GET /dev/config — the capability/handshake document. Lazy + cached for the client's
289
+ * lifetime; a missing route, a 401, or any transport/parse error falls back to
290
+ * DISCOVERY_DEFAULTS, so this can never be the reason a command fails.
291
+ *
292
+ * ⭐ **Authenticated, or not sent at all.** `/dev/*` is the CLI's lane and the CLI is
293
+ * an authenticated client, so this attaches the bearer when it has one and **makes no
294
+ * request when it does not** — which keeps that true by construction rather than by
295
+ * ordering luck, and makes the route moving behind auth a no-op here.
296
+ *
297
+ * ⚠️ It uses `_tokenIfAvailable()` and never `token()`, deliberately: **discovery must
298
+ * never be the thing that triggers a login.** A capability probe that opens a password
299
+ * prompt would be a worse defect than the anonymous call it replaced.
300
+ *
301
+ * ⛔ **Most of this document is deliberately not read.** `gatewayBase` and `assetBase`
302
+ * were dropped because a serve location is read from the response that carries it
303
+ * (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
304
+ * is not read either — the login path is a hardcoded constant
305
+ * (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
306
+ * `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
307
+ * rest: each one would be a second place a backend's layout is pinned.
308
+ *
259
309
  * @returns {Promise<object>}
260
310
  */
261
311
  async discover() {
262
312
  if (this._discovery) return this._discovery
313
+ const bearer = await this._tokenIfAvailable()
314
+
315
+ // ⛔ NO CREDENTIAL ⇒ NO REQUEST. This is the rule made structural rather than
316
+ // incidental: apart from the login routes themselves, the CLI does not touch
317
+ // `/dev/*` without a bearer. The defaults are the honest answer here — we do not
318
+ // know this backend's capabilities and are not entitled to ask yet — and every
319
+ // caller already treats them as non-breaking, so nothing downstream changes.
320
+ if (!bearer) {
321
+ this._discovery = { ...DISCOVERY_DEFAULTS }
322
+ return this._discovery
323
+ }
324
+
263
325
  try {
264
- const res = await this.request('/dev/config', { auth: false })
326
+ const res = await this.request('/dev/config', {
327
+ auth: false,
328
+ headers: { Authorization: `Bearer ${bearer}` }
329
+ })
265
330
  this._discovery = res.ok ? await res.json() : { ...DISCOVERY_DEFAULTS }
266
331
  } catch {
267
332
  this._discovery = { ...DISCOVERY_DEFAULTS }
@@ -519,7 +584,29 @@ export class BackendClient {
519
584
  * shipped backend-side — collab backend-framework-b220):
520
585
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
521
586
  * `draft_dirty` = never-published, or the synced draft changed since the last
522
- * publish ("pushed but not published"). The path is VERB-FIRST (`status/{uuid}`)
587
+ * publish ("pushed but not published").
588
+ *
589
+ * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
590
+ * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
591
+ *
592
+ * last_published_url · last_published_foundation · last_published_extensions
593
+ * last_published_runtime · last_published_runtime_floor · last_published_runtime_resolution
594
+ *
595
+ * Three things about it that a reader will otherwise get wrong:
596
+ *
597
+ * ⛔ `runtime_resolution` is `resolved` or `pinned:<reason>` (`operator` / `unknown_floor` /
598
+ * `no_foundations`). **A pin is a first-class answer, not a failure** — most sites are pinned
599
+ * at any moment, and an "old" runtime still satisfies the site's floor. Never surface
600
+ * `pinned:*` as an error state.
601
+ * ⛔ `extensions` is there because a site's code surface is the primary foundation **plus N
602
+ * extensions**; reading the primary alone describes a site nobody has.
603
+ * ⛔ `last_*` is deliberate on every one. `unpublish` LEAVES the URL populated (the static site
604
+ * may still be reachable), so beside `published: bool` a bare `published_url` would read as a
605
+ * liveness claim and be wrong exactly when it matters. And **nothing back-fills** — a site
606
+ * published before this reports them absent, which means "published before we recorded it",
607
+ * never "has no foundation".
608
+ *
609
+ * The path is VERB-FIRST (`status/{uuid}`)
523
610
  * to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
524
611
  * not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
525
612
  * 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
@@ -859,28 +859,27 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
859
859
  : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
860
860
  )
861
861
 
862
- // The billing line needs the JOIN of two independent facts, and either alone
863
- // gives a wrong answer:
864
- // hosts_free a property of the SCOPE (is this owner exempt?)
865
- // siteSubscriptionRequired a property of the DEPLOYMENT (does it charge at all?)
866
- // Keyed on the scope alone, this fires on every local publish where nothing
867
- // enforces until the warning is trained away. Keyed on the deployment alone it
868
- // fires at exempt owners. An older backend supplies neither, so both read falsy
869
- // and nothing is said: silence beats a claim we cannot justify.
870
- const hostsFree = payload?.hosts_free === true
871
- let enforces = false
872
- try {
873
- const cfg = await client.discover()
874
- enforces = cfg?.delivery?.siteSubscriptionRequired === true
875
- } catch {
876
- /* discovery is advisory here never fail a create over a message */
877
- }
878
- if (hostsFree) {
862
+ // What the create echoed about this site's OWNER, and nothing beyond it.
863
+ //
864
+ // `hosts_free` is a property of the SCOPE is this owner exempt? — and it is the only
865
+ // billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
866
+ // from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
867
+ // every deployment charges, so it read true everywhere and the join was testing a
868
+ // constant.
869
+ //
870
+ // AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
871
+ // alone, which is the exact thing the join existed to prevent. Whether a given publish
872
+ // is charged is derived per-site at publish time, on a side the CLI cannot see, so any
873
+ // prediction made here can only be approximately right and would go stale silently.
874
+ // The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
875
+ // arrives when it matters; `backend/payment-handoff.js` already turns it into a
876
+ // checkout. Deciding whether payment is due is not the CLI's job.
877
+ //
878
+ // What survives is the reassuring direction only, and only when it was stated:
879
+ // `false` is an answer we deliberately do not speak to, and missing is not an answer
880
+ // at all. Do not reintroduce a "you will be charged" line here.
881
+ if (payload?.hosts_free === true) {
879
882
  note?.('This owner is hosted free — publishing will not require a subscription.')
880
- } else if (enforces) {
881
- note?.(
882
- 'Publishing this site live will require a hosting subscription on this backend.'
883
- )
884
883
  }
885
884
  return org
886
885
  }
@@ -248,15 +248,17 @@ export async function publish(args = []) {
248
248
  }
249
249
  const asOrg = org.asOrg
250
250
 
251
- // Capability handshake (cached). Publish ends in a go-live, so the publish
252
- // lane must be offered.
253
- const config = await client.discover()
254
- if (config?.delivery && config.delivery.publish === false) {
255
- say.err(
256
- `Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`
257
- )
258
- return { exitCode: 1 }
259
- }
251
+ // There is no capability gate here any more, deliberately.
252
+ //
253
+ // This used to read `delivery.publish` from the discovery document and refuse when it
254
+ // was false. It could never fire: the backend sent a literal true for every deployment,
255
+ // so the gate compared a constant against false. The key is now gone on both sides
256
+ // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
257
+ // while implying a capability that was never negotiable.
258
+ //
259
+ // Discovery is not consulted on this path at all. The one leaf the CLI still reads
260
+ // (`delivery.siteSubscriptionRequired`) is read after the site create, where a
261
+ // credential is already in hand.
260
262
 
261
263
  // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
262
264
  // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-29T19:59:22.061Z",
3
+ "generatedAt": "2026-08-31T13:37:55.117Z",
4
4
  "packages": {
5
+ "@uniweb/api": {
6
+ "version": "0.1.0",
7
+ "path": "framework/api",
8
+ "deps": [
9
+ "@uniweb/core"
10
+ ]
11
+ },
5
12
  "@uniweb/build": {
6
13
  "version": "0.30.0",
7
14
  "path": "framework/build",
@@ -27,7 +34,7 @@
27
34
  "deps": []
28
35
  },
29
36
  "@uniweb/core": {
30
- "version": "0.14.0",
37
+ "version": "0.14.1",
31
38
  "path": "framework/core",
32
39
  "deps": [
33
40
  "@uniweb/semantic-parser",
@@ -74,7 +81,7 @@
74
81
  ]
75
82
  },
76
83
  "@uniweb/runtime": {
77
- "version": "0.13.2",
84
+ "version": "0.13.3",
78
85
  "path": "framework/runtime",
79
86
  "deps": [
80
87
  "@uniweb/core",
@@ -102,7 +109,7 @@
102
109
  "deps": []
103
110
  },
104
111
  "@uniweb/templates": {
105
- "version": "0.10.0",
112
+ "version": "0.11.0",
106
113
  "path": "framework/templates",
107
114
  "deps": []
108
115
  },
@@ -112,7 +119,7 @@
112
119
  "deps": []
113
120
  },
114
121
  "@uniweb/unipress": {
115
- "version": "0.8.16",
122
+ "version": "0.8.17",
116
123
  "path": "framework/unipress",
117
124
  "deps": [
118
125
  "@uniweb/build",
package/src/index.js CHANGED
@@ -814,10 +814,45 @@ async function main() {
814
814
  const originFlag =
815
815
  readFlagValue(loginArgs, '--backend') ||
816
816
  readFlagValue(loginArgs, '--registry')
817
- await runRegistryLogin({
818
- apiBase: resolveBackendOrigin(originFlag),
819
- args: loginArgs
820
- })
817
+ const apiBase = resolveBackendOrigin(originFlag)
818
+
819
+ // ⭐ The project says where it belongs — say so BEFORE authenticating elsewhere.
820
+ //
821
+ // `login` is deliberately NOT given the `site.yml::$backend` tier the site verbs
822
+ // get (see resolveBackendOrigin): the session it writes is machine-wide, so letting
823
+ // cwd pick the account you log into would be a silent surprise. But staying silent
824
+ // does not remove the failure, it MOVES it — you log into the default, and the next
825
+ // push/pull/publish resolves to `$backend` and warns about the mismatch. That is the
826
+ // routed-not-nagged case `$backend` was added for, missing at the one command a
827
+ // teammate runs FIRST after cloning.
828
+ //
829
+ // ⛔ Silent when the origin was named explicitly (--backend / --registry /
830
+ // UNIWEB_REGISTER_URL). A deliberate aim is not a mistake to warn about; a genuinely
831
+ // wrong one is still caught by the session-mismatch guard in BackendClient.token().
832
+ if (!originFlag && !process.env.UNIWEB_REGISTER_URL) {
833
+ try {
834
+ const { findNearbySiteBackend } = await import(
835
+ './utils/site-identity.js'
836
+ )
837
+ const nearby = findNearbySiteBackend(process.cwd())
838
+ if (nearby && nearby.backend !== apiBase) {
839
+ console.error(
840
+ `\x1b[33m⚠\x1b[0m This project syncs with ${nearby.backend} (site.yml::$backend), but login is targeting ${apiBase}.`
841
+ )
842
+ console.error(
843
+ ` Log in where the project belongs: uniweb login --backend ${nearby.backend}`
844
+ )
845
+ console.error(
846
+ ` Continuing with ${apiBase} — a session is machine-wide, so this is a heads-up, not a block.\n`
847
+ )
848
+ }
849
+ } catch {
850
+ // Advisory only. A malformed site.yml, an unreadable directory or anything else
851
+ // here must never be the reason someone cannot log in.
852
+ }
853
+ }
854
+
855
+ await runRegistryLogin({ apiBase, args: loginArgs })
821
856
  return
822
857
  }
823
858
 
@@ -28,8 +28,8 @@
28
28
  * and it has to move the readers and the writers together or it makes the split worse.
29
29
  */
30
30
 
31
- import { existsSync, readFileSync } from 'node:fs'
32
- import { join } from 'node:path'
31
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
32
+ import { join, dirname } from 'node:path'
33
33
  import yaml from 'js-yaml'
34
34
  import { DEFAULT_BACKEND_ORIGIN } from './config.js'
35
35
 
@@ -232,3 +232,66 @@ export function assertSiteBackendScope(siteDir, origin) {
232
232
  hint
233
233
  }
234
234
  }
235
+
236
+ /**
237
+ * The `$backend` of the site project `startDir` sits in — for verbs that are NOT site
238
+ * verbs and so never resolve a site directory of their own.
239
+ *
240
+ * ⛔ **This does NOT feed the origin ladder, deliberately.** `login` writes a
241
+ * MACHINE-WIDE session (`~/.uniweb/registry-auth.json`), not a per-project one, so
242
+ * letting whichever directory you happen to stand in decide which backend you
243
+ * authenticate against would be a silent surprise — the same class of surprise
244
+ * `$backend` exists to remove. What this enables is a **notice**: the project says
245
+ * where it belongs, so we say so before authenticating somewhere else.
246
+ *
247
+ * ⭐ **Conservative by construction — it answers only when there is exactly ONE
248
+ * candidate.** A workspace of several sites has no single answer, and a confident
249
+ * *"did you mean localhost?"* aimed at the wrong one of three sites is worse than
250
+ * saying nothing. Ambiguity returns null and the caller stays quiet.
251
+ *
252
+ * ⚠️ Build-free, like everything else in this file — `login` is a STANDALONE command
253
+ * that must work outside a project, where `@uniweb/build` is not installed. That is why
254
+ * this cannot reuse `resolveSiteDir` (`commands/deploy.js`), which pulls build in.
255
+ *
256
+ * @param {string} startDir
257
+ * @returns {{ siteDir: string, backend: string }|null}
258
+ */
259
+ export function findNearbySiteBackend(startDir) {
260
+ // 1. Walk UP for the site we are standing in or under. Bounded: a `site.yml` more
261
+ // than a few levels above is not "the project you are in", it is a coincidence,
262
+ // and at the filesystem root it would be someone else's entirely.
263
+ let dir = startDir
264
+ for (let i = 0; i < 4; i++) {
265
+ if (existsSync(join(dir, 'site.yml'))) {
266
+ const { backend } = readSiteIdentity(dir)
267
+ return backend ? { siteDir: dir, backend } : null
268
+ }
269
+ const up = dirname(dir)
270
+ if (up === dir) break
271
+ dir = up
272
+ }
273
+
274
+ // 2. Standing AT a project root, the site is one level down — `site/` in the default
275
+ // layout, or a lone entry under `sites/`. Two or more candidates is a workspace,
276
+ // which is exactly the ambiguity above.
277
+ const candidates = []
278
+ if (existsSync(join(startDir, 'site', 'site.yml')))
279
+ candidates.push(join(startDir, 'site'))
280
+ const sitesDir = join(startDir, 'sites')
281
+ if (existsSync(sitesDir)) {
282
+ let entries = []
283
+ try {
284
+ entries = readdirSync(sitesDir, { withFileTypes: true })
285
+ } catch {
286
+ entries = []
287
+ }
288
+ for (const e of entries) {
289
+ if (!e.isDirectory()) continue
290
+ const d = join(sitesDir, e.name)
291
+ if (existsSync(join(d, 'site.yml'))) candidates.push(d)
292
+ }
293
+ }
294
+ if (candidates.length !== 1) return null
295
+ const { backend } = readSiteIdentity(candidates[0])
296
+ return backend ? { siteDir: candidates[0], backend } : null
297
+ }