uniweb 0.34.0 → 0.34.2
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 +106 -0
- package/src/backend/client.js +122 -35
- package/src/backend/site-media.js +12 -17
- package/src/backend/site-sync.js +97 -32
- package/src/commands/publish.js +11 -9
- package/src/framework-index.json +26 -7
- package/src/index.js +39 -4
- package/src/utils/bytes.js +30 -0
- package/src/utils/site-identity.js +65 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.2",
|
|
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/
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "^0.14.1",
|
|
45
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
46
|
+
"@uniweb/runtime": "^0.13.3",
|
|
47
|
+
"@uniweb/kit": "^0.15.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.30.0",
|
|
51
50
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
52
|
-
"@uniweb/content-reader": "^1.2.4"
|
|
51
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
+
"@uniweb/build": "^0.30.2"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2234,6 +2234,112 @@ For cases the factory doesn't cover, write handlers directly using `Loom`, `inst
|
|
|
2234
2234
|
|
|
2235
2235
|
---
|
|
2236
2236
|
|
|
2237
|
+
## Part 4b — When the site is also an app
|
|
2238
|
+
|
|
2239
|
+
Everything above is a site: content the author writes, built into pages. Some sites
|
|
2240
|
+
also have **their own backend** — accounts, per-visitor data, records their members
|
|
2241
|
+
create and edit. That is `@uniweb/api`.
|
|
2242
|
+
|
|
2243
|
+
⛔ **Only reach for this when the site actually has one.** A site with no backend is
|
|
2244
|
+
the normal case, and a foundation that assumes one breaks on every other site it is
|
|
2245
|
+
used with.
|
|
2246
|
+
|
|
2247
|
+
```bash
|
|
2248
|
+
npm install @uniweb/api # in the FOUNDATION, beside @uniweb/kit
|
|
2249
|
+
```
|
|
2250
|
+
|
|
2251
|
+
### Ask before you draw
|
|
2252
|
+
|
|
2253
|
+
```jsx
|
|
2254
|
+
import { isEnabled, useSession, SignedIn, SignedOut } from '@uniweb/api'
|
|
2255
|
+
|
|
2256
|
+
if (!isEnabled(website)) return <StaticVersion /> // synchronous — nothing to await
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
⛔ **When there is no backend, draw nothing** — not a disabled control, and not an
|
|
2260
|
+
explanation. Same rule as `services` in Part 4: which capabilities a site's operator
|
|
2261
|
+
set up is none of a visitor's business, and "sign-in unavailable" reads as breakage
|
|
2262
|
+
when it is simply a feature this site does not have. Render the version of your
|
|
2263
|
+
component that never needed one.
|
|
2264
|
+
|
|
2265
|
+
### Reading
|
|
2266
|
+
|
|
2267
|
+
```jsx
|
|
2268
|
+
const { status, records } = useRecords({ schema: '@/session' })
|
|
2269
|
+
```
|
|
2270
|
+
|
|
2271
|
+
⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
|
|
2272
|
+
mistake to avoid.** `absent` = there is no live source (no backend, or nobody signed
|
|
2273
|
+
in) → render the site's authored content. `ready` with `records: []` = the backend
|
|
2274
|
+
answered and there is nothing there → render your empty state. Showing "nothing yet"
|
|
2275
|
+
for the first tells a visitor their content is gone when it was never requested.
|
|
2276
|
+
|
|
2277
|
+
### Writing
|
|
2278
|
+
|
|
2279
|
+
```jsx
|
|
2280
|
+
const writer = useEntityWriter({ schema: '@/track', uuid: track.uuid })
|
|
2281
|
+
|
|
2282
|
+
await writer.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
|
|
2283
|
+
await writer.update(itemId, { ...item.data, room: 'Hall A' }) // whole-data replace
|
|
2284
|
+
await writer.move(itemId, { after: previousItemId }) // never an index
|
|
2285
|
+
await writer.remove(itemId)
|
|
2286
|
+
```
|
|
2287
|
+
|
|
2288
|
+
- **`section` is required on `create`.** An entity has several, and a rule declared on
|
|
2289
|
+
one — insert-only, say — does not reach an item that landed in another. Getting it
|
|
2290
|
+
wrong stores the item happily and quietly voids the rule.
|
|
2291
|
+
- **`update` replaces the item's data whole.** Spread what you are not editing.
|
|
2292
|
+
- **Ordering is the server's.** Say `'first'`, `'last'` or `{ after }` — never compute
|
|
2293
|
+
an order number, or two people arranging one list will produce an order neither
|
|
2294
|
+
chose.
|
|
2295
|
+
- ⛔ **`writer.conflict` is reported, not retried.** Someone else changed the item
|
|
2296
|
+
first; a retry would succeed by overwriting a change nobody looked at. Tell the
|
|
2297
|
+
person.
|
|
2298
|
+
|
|
2299
|
+
### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
|
|
2300
|
+
|
|
2301
|
+
Gate your controls on what the viewer may do — `viewer.actingUnitId`, `viewer.roles` —
|
|
2302
|
+
but **never rely on that for safety**. A foundation runs with exactly the viewer's
|
|
2303
|
+
own authority, so hiding a button hides a button. The rule belongs in the data
|
|
2304
|
+
schema, where the store enforces it:
|
|
2305
|
+
|
|
2306
|
+
```yaml
|
|
2307
|
+
# foundation/schemas/session.yml
|
|
2308
|
+
creatable_by: unit_members # only members of the owning unit may create these
|
|
2309
|
+
|
|
2310
|
+
sections:
|
|
2311
|
+
checkins:
|
|
2312
|
+
many: true
|
|
2313
|
+
append_only: true # may be added; never edited or removed, by anyone
|
|
2314
|
+
```
|
|
2315
|
+
|
|
2316
|
+
`append_only` holds against the item's own author. That is the difference between a
|
|
2317
|
+
permission model and a CSS one.
|
|
2318
|
+
|
|
2319
|
+
### A backend on your machine
|
|
2320
|
+
|
|
2321
|
+
You do not need a live backend to build against one. In `site.yml`:
|
|
2322
|
+
|
|
2323
|
+
```yaml
|
|
2324
|
+
api: /_api # where it answers — the same value in production
|
|
2325
|
+
$devApi: ./mock/api.js # what answers it locally; `$` keys are never published
|
|
2326
|
+
```
|
|
2327
|
+
|
|
2328
|
+
```js
|
|
2329
|
+
// site/mock/api.js
|
|
2330
|
+
import { createMockBackend } from '@uniweb/api/mock'
|
|
2331
|
+
export default createMockBackend({ seed }).fetch
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
`uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
|
|
2335
|
+
site's configuration behave exactly as they will in production. It **enforces**
|
|
2336
|
+
`creatable_by` and `append_only`, so a permission you are relying on fails on your
|
|
2337
|
+
machine rather than in front of a user. State is in memory; restart to reset.
|
|
2338
|
+
|
|
2339
|
+
`uniweb create my-event --template conference` is a worked example of all of this.
|
|
2340
|
+
|
|
2341
|
+
---
|
|
2342
|
+
|
|
2237
2343
|
## Part 5 — Commands, shipping, and migration
|
|
2238
2344
|
|
|
2239
2345
|
```bash
|
package/src/backend/client.js
CHANGED
|
@@ -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`
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
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', {
|
|
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").
|
|
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.
|
|
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
17
17
|
import { basename } from 'node:path'
|
|
18
18
|
import { resolveAssetPath } from '@uniweb/build/site'
|
|
19
19
|
import { contentTypeFor } from '../utils/code-upload.js'
|
|
20
|
+
import { humanBytes } from '../utils/bytes.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
|
|
@@ -123,20 +124,6 @@ export async function uploadSiteMedia(
|
|
|
123
124
|
// rewording). Same house style as the push-staleness `reason: "stale_base"` in
|
|
124
125
|
// site-sync.js.
|
|
125
126
|
|
|
126
|
-
const KIB = 1024
|
|
127
|
-
function humanBytes(n) {
|
|
128
|
-
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
|
|
129
|
-
if (n < KIB) return `${n} B`
|
|
130
|
-
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
|
131
|
-
let v = n / KIB
|
|
132
|
-
let i = 0
|
|
133
|
-
while (v >= KIB && i < units.length - 1) {
|
|
134
|
-
v /= KIB
|
|
135
|
-
i++
|
|
136
|
-
}
|
|
137
|
-
return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
127
|
// Append `label: <bytes>` when the value is a usable number. A refusal that omits an
|
|
141
128
|
// extra still produces a useful message — never print `undefined` at a user.
|
|
142
129
|
function pushBytes(lines, label, n) {
|
|
@@ -146,9 +133,17 @@ function pushBytes(lines, label, n) {
|
|
|
146
133
|
|
|
147
134
|
/**
|
|
148
135
|
* Turn an asset-plan refusal into user-facing lines, or null when this is not a
|
|
149
|
-
* refusal we recognise
|
|
150
|
-
*
|
|
151
|
-
*
|
|
136
|
+
* refusal we recognise. Null means "fall through to the generic error", so this
|
|
137
|
+
* degrades rather than swallowing.
|
|
138
|
+
*
|
|
139
|
+
* ⛔ This comment used to assert that the typed `reason` values were "agreed but
|
|
140
|
+
* NOT YET EMITTED" — an unverified claim about ANOTHER LANE's deployed state,
|
|
141
|
+
* sitting in our source where it read as fact and nobody would think to re-check
|
|
142
|
+
* it. That is the precise shape that cost the backend lane a metering hole (a
|
|
143
|
+
* comment claiming what the app sent, wrong the whole time). What we can say is
|
|
144
|
+
* ours: we branch on `reason` and fall through cleanly when there is none, so
|
|
145
|
+
* this function is correct whether or not any given backend emits one, and no
|
|
146
|
+
* sentence here needs to track what a deployment is running.
|
|
152
147
|
*
|
|
153
148
|
* Two wording rules are load-bearing and come from the ratified accounting model,
|
|
154
149
|
* not from taste:
|
package/src/backend/site-sync.js
CHANGED
|
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path'
|
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { hasUncommittedContent } from '../utils/git.js'
|
|
18
18
|
import { recordSiteBackend } from '../utils/site-identity.js'
|
|
19
|
+
import { humanBytes } from '../utils/bytes.js'
|
|
19
20
|
import {
|
|
20
21
|
backfillEntityUuids,
|
|
21
22
|
writeSiteEntityUuid,
|
|
@@ -647,9 +648,8 @@ export async function resolveSiteOrgForCreate({
|
|
|
647
648
|
// serves the foundation lane, where every answer is an org and 0-orgs means
|
|
648
649
|
// "claim your personal org". Here "personal" must stay reachable as *no org*,
|
|
649
650
|
// and reusing deriveScope would quietly turn it into an org creation.
|
|
650
|
-
const { fetchOrgs, createOrg, validateHandle, bareHandle } =
|
|
651
|
-
'../utils/registry-orgs.js'
|
|
652
|
-
)
|
|
651
|
+
const { fetchOrgs, createOrg, validateHandle, bareHandle } =
|
|
652
|
+
await import('../utils/registry-orgs.js')
|
|
653
653
|
let envelope
|
|
654
654
|
try {
|
|
655
655
|
envelope = await fetchOrgs({
|
|
@@ -719,6 +719,59 @@ export async function resolveSiteOrgForCreate({
|
|
|
719
719
|
}
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Turn a typed site-create refusal into a sentence, or null to fall through to the
|
|
724
|
+
* generic HTTP line.
|
|
725
|
+
*
|
|
726
|
+
* ⭐ Why this exists separately from `describeAssetRefusal`. The asset lane grew a
|
|
727
|
+
* typed describer because a raw status dump is useless to the person who has to act
|
|
728
|
+
* on it; the create lane had the same hole and nobody had met it, because until the
|
|
729
|
+
* backend shipped account capacity a create could not be refused for space at all.
|
|
730
|
+
* So this is the same fix on the second door, written before anyone hits it rather
|
|
731
|
+
* than after.
|
|
732
|
+
*
|
|
733
|
+
* ⛔ Branch on `reason`, never on the status. `507` alone cannot be told from any
|
|
734
|
+
* other 507 and carries none of the numbers a user needs, and `detail` is prose the
|
|
735
|
+
* backend may reword.
|
|
736
|
+
*
|
|
737
|
+
* ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
|
|
738
|
+
* allowance belongs to the site's owner, who may not be the person pushing. Here
|
|
739
|
+
* there is no site yet, so the only workspace in play is the one the site would be
|
|
740
|
+
* created in — `--as <org>` included. Saying "the site owner's workspace" would be
|
|
741
|
+
* incoherent for a site that does not exist.
|
|
742
|
+
*
|
|
743
|
+
* @param {string} body - the raw response body
|
|
744
|
+
* @returns {string|null}
|
|
745
|
+
*/
|
|
746
|
+
function describeCreateRefusal(body) {
|
|
747
|
+
if (!body) return null
|
|
748
|
+
let p
|
|
749
|
+
try {
|
|
750
|
+
p = JSON.parse(body)
|
|
751
|
+
} catch {
|
|
752
|
+
return null // prose refusal, or an upstream error page
|
|
753
|
+
}
|
|
754
|
+
if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
|
|
755
|
+
|
|
756
|
+
if (p.reason === 'storage_quota_exceeded') {
|
|
757
|
+
const parts = []
|
|
758
|
+
const used = humanBytes(p.used_bytes)
|
|
759
|
+
const limit = humanBytes(p.limit_bytes)
|
|
760
|
+
const needed = humanBytes(p.needed_bytes)
|
|
761
|
+
if (used && limit) parts.push(`${used} of ${limit} used`)
|
|
762
|
+
if (needed) parts.push(`a new site needs ${needed}`)
|
|
763
|
+
return (
|
|
764
|
+
'storage quota reached — the workspace this site would belong to has no room for it' +
|
|
765
|
+
(parts.length ? ` (${parts.join('; ')})` : '') +
|
|
766
|
+
'. Quota is returned by deleting a site or entity, not by editing content.'
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
// An unrecognised typed reason still beats a status dump: name it, and let the
|
|
770
|
+
// backend's own prose follow when it sent any.
|
|
771
|
+
const detail = typeof p.detail === 'string' ? p.detail : ''
|
|
772
|
+
return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
|
|
773
|
+
}
|
|
774
|
+
|
|
722
775
|
/**
|
|
723
776
|
* Guarantee the site EXISTS on the backend before anything is uploaded against it.
|
|
724
777
|
*
|
|
@@ -797,7 +850,8 @@ export async function ensureSiteExists({
|
|
|
797
850
|
reason:
|
|
798
851
|
res?.status === 404
|
|
799
852
|
? 'this backend has no /dev/site route (it predates the empty-site create)'
|
|
800
|
-
:
|
|
853
|
+
: (describeCreateRefusal(body) ??
|
|
854
|
+
`HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`)
|
|
801
855
|
}
|
|
802
856
|
}
|
|
803
857
|
const payload = await res.json().catch(() => null)
|
|
@@ -837,7 +891,13 @@ export async function ensureSiteExists({
|
|
|
837
891
|
*
|
|
838
892
|
* @returns {Promise<string|null>} the display handle recorded, or null for personal
|
|
839
893
|
*/
|
|
840
|
-
async function recordAndDescribeOwner({
|
|
894
|
+
async function recordAndDescribeOwner({
|
|
895
|
+
client,
|
|
896
|
+
siteDir,
|
|
897
|
+
payload,
|
|
898
|
+
asOrg,
|
|
899
|
+
note
|
|
900
|
+
}) {
|
|
841
901
|
const echoed = payload && 'org' in payload ? payload.org : undefined
|
|
842
902
|
const owner =
|
|
843
903
|
echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
|
|
@@ -851,7 +911,8 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
|
|
|
851
911
|
//
|
|
852
912
|
// A no-op on the default backend, so the common case writes nothing.
|
|
853
913
|
const scope = await recordSiteBackend(siteDir, client.origin)
|
|
854
|
-
if (scope)
|
|
914
|
+
if (scope)
|
|
915
|
+
note?.(`Bound this project to ${scope} (recorded $backend in site.yml).`)
|
|
855
916
|
|
|
856
917
|
note?.(
|
|
857
918
|
org
|
|
@@ -859,27 +920,28 @@ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note })
|
|
|
859
920
|
: `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
|
|
860
921
|
)
|
|
861
922
|
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
923
|
+
// What the create echoed about this site's OWNER, and nothing beyond it.
|
|
924
|
+
//
|
|
925
|
+
// `hosts_free` is a property of the SCOPE — is this owner exempt? — and it is the only
|
|
926
|
+
// billing fact the CLI holds. This used to JOIN it with `siteSubscriptionRequired`
|
|
927
|
+
// from the discovery document, a property of the DEPLOYMENT. That leaf left the wire:
|
|
928
|
+
// every deployment charges, so it read true everywhere and the join was testing a
|
|
929
|
+
// constant.
|
|
930
|
+
//
|
|
931
|
+
// ⛔ AND THE WARNING WENT WITH IT — this is NOT a fallback to keying on the scope
|
|
932
|
+
// alone, which is the exact thing the join existed to prevent. Whether a given publish
|
|
933
|
+
// is charged is derived per-site at publish time, on a side the CLI cannot see, so any
|
|
934
|
+
// prediction made here can only be approximately right and would go stale silently.
|
|
935
|
+
// The backend's typed 402 (`reason: "no_subscription"`) is exact, per-site, and
|
|
936
|
+
// arrives when it matters; `backend/payment-handoff.js` already turns it into a
|
|
937
|
+
// checkout. Deciding whether payment is due is not the CLI's job.
|
|
938
|
+
//
|
|
939
|
+
// ⇒ What survives is the reassuring direction only, and only when it was stated:
|
|
940
|
+
// `false` is an answer we deliberately do not speak to, and missing is not an answer
|
|
941
|
+
// at all. Do not reintroduce a "you will be charged" line here.
|
|
942
|
+
if (payload?.hosts_free === true) {
|
|
881
943
|
note?.(
|
|
882
|
-
'
|
|
944
|
+
'This owner is hosted free — publishing will not require a subscription.'
|
|
883
945
|
)
|
|
884
946
|
}
|
|
885
947
|
return org
|
|
@@ -1004,7 +1066,10 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
|
|
|
1004
1066
|
* Offline by design — measured at zero HTTP requests, a property the cross-client
|
|
1005
1067
|
* flows rely on.
|
|
1006
1068
|
*/
|
|
1007
|
-
async function comparisonEmit(
|
|
1069
|
+
async function comparisonEmit(
|
|
1070
|
+
siteDir,
|
|
1071
|
+
{ priorHashes = {}, sendAll = false } = {}
|
|
1072
|
+
) {
|
|
1008
1073
|
const applied = readAppliedInjections(siteDir)
|
|
1009
1074
|
const assetIds = readAssetMap(siteDir)
|
|
1010
1075
|
const org = readSiteOrg(siteDir)
|
|
@@ -1265,9 +1330,7 @@ export async function pushSyncPackages({
|
|
|
1265
1330
|
note(
|
|
1266
1331
|
` deleted in the app → clearing \`$uuid\` from site.yml re-publishes it as a NEW site`
|
|
1267
1332
|
)
|
|
1268
|
-
note(
|
|
1269
|
-
'Deleting this folder removes only your local copy, either way.'
|
|
1270
|
-
)
|
|
1333
|
+
note('Deleting this folder removes only your local copy, either way.')
|
|
1271
1334
|
} else if (res.status === 409) {
|
|
1272
1335
|
// The site's @uniweb/folder is genesis-owned: its structure is fixed on first
|
|
1273
1336
|
// deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
|
|
@@ -1417,7 +1480,8 @@ export async function pushSyncPackages({
|
|
|
1417
1480
|
asOrg,
|
|
1418
1481
|
note
|
|
1419
1482
|
})
|
|
1420
|
-
if (createdOrg)
|
|
1483
|
+
if (createdOrg)
|
|
1484
|
+
wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
|
|
1421
1485
|
const createdFinalized = extractFinalized(payload)
|
|
1422
1486
|
harvest(createdFinalized)
|
|
1423
1487
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
@@ -1465,7 +1529,8 @@ export async function pushSyncPackages({
|
|
|
1465
1529
|
const folderDoc = finalized.find((f) => f?.document?.contents)?.document
|
|
1466
1530
|
if (folderDoc) {
|
|
1467
1531
|
const placements = collectFolderItemUuids(folderDoc)
|
|
1468
|
-
if (Object.keys(placements).length)
|
|
1532
|
+
if (Object.keys(placements).length)
|
|
1533
|
+
writeFolderItemUuids(siteDir, placements)
|
|
1469
1534
|
}
|
|
1470
1535
|
finalizedTotal += finalized.length
|
|
1471
1536
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -248,15 +248,17 @@ export async function publish(args = []) {
|
|
|
248
248
|
}
|
|
249
249
|
const asOrg = org.asOrg
|
|
250
250
|
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-09-01T22:08:19.670Z",
|
|
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
|
-
"version": "0.30.
|
|
13
|
+
"version": "0.30.2",
|
|
7
14
|
"path": "framework/build",
|
|
8
15
|
"deps": [
|
|
9
16
|
"@uniweb/content-reader",
|
|
@@ -27,7 +34,7 @@
|
|
|
27
34
|
"deps": []
|
|
28
35
|
},
|
|
29
36
|
"@uniweb/core": {
|
|
30
|
-
"version": "0.14.
|
|
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.
|
|
84
|
+
"version": "0.13.3",
|
|
78
85
|
"path": "framework/runtime",
|
|
79
86
|
"deps": [
|
|
80
87
|
"@uniweb/core",
|
|
@@ -87,7 +94,7 @@
|
|
|
87
94
|
"deps": []
|
|
88
95
|
},
|
|
89
96
|
"@uniweb/schemas": {
|
|
90
|
-
"version": "0.2.
|
|
97
|
+
"version": "0.2.12",
|
|
91
98
|
"path": "framework/schemas",
|
|
92
99
|
"deps": []
|
|
93
100
|
},
|
|
@@ -102,7 +109,7 @@
|
|
|
102
109
|
"deps": []
|
|
103
110
|
},
|
|
104
111
|
"@uniweb/templates": {
|
|
105
|
-
"version": "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.
|
|
122
|
+
"version": "0.8.17",
|
|
116
123
|
"path": "framework/unipress",
|
|
117
124
|
"deps": [
|
|
118
125
|
"@uniweb/build",
|
|
@@ -291,6 +298,18 @@
|
|
|
291
298
|
"quote",
|
|
292
299
|
"trades"
|
|
293
300
|
]
|
|
301
|
+
},
|
|
302
|
+
"conference": {
|
|
303
|
+
"name": "Conference",
|
|
304
|
+
"description": "A site that is also an app — sign-in, per-viewer data, and an editable programme. Ships a local backend so it runs with nothing installed.",
|
|
305
|
+
"tags": [
|
|
306
|
+
"app",
|
|
307
|
+
"api",
|
|
308
|
+
"backend",
|
|
309
|
+
"auth",
|
|
310
|
+
"permissions",
|
|
311
|
+
"conference"
|
|
312
|
+
]
|
|
294
313
|
}
|
|
295
314
|
}
|
|
296
315
|
}
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte formatting for refusal messages.
|
|
3
|
+
*
|
|
4
|
+
* Lives on its own because TWO lanes now print a storage refusal — the asset plan
|
|
5
|
+
* (`backend/site-media.js`) and the site create (`backend/site-sync.js`) — and the
|
|
6
|
+
* numbers must read identically in both. A second copy would drift in units or
|
|
7
|
+
* rounding, and a user comparing "you have 1.1 GiB" against "this needs 100 MB"
|
|
8
|
+
* cannot tell a real gap from a formatting difference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const KIB = 1024
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `1073741824` → `1 GiB`. Returns null for anything that is not a usable byte
|
|
15
|
+
* count, so a refusal that omits an extra prints nothing rather than `undefined`.
|
|
16
|
+
* @param {unknown} n
|
|
17
|
+
* @returns {string|null}
|
|
18
|
+
*/
|
|
19
|
+
export function humanBytes(n) {
|
|
20
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
|
|
21
|
+
if (n < KIB) return `${n} B`
|
|
22
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
|
23
|
+
let v = n / KIB
|
|
24
|
+
let i = 0
|
|
25
|
+
while (v >= KIB && i < units.length - 1) {
|
|
26
|
+
v /= KIB
|
|
27
|
+
i++
|
|
28
|
+
}
|
|
29
|
+
return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|