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 +5 -5
- package/src/backend/client.js +122 -35
- package/src/backend/site-sync.js +20 -21
- package/src/commands/publish.js +11 -9
- package/src/framework-index.json +12 -5
- package/src/index.js +39 -4
- 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.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/
|
|
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/
|
|
50
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
51
51
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/build": "^0.30.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
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.
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
}
|
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,7 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
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.
|
|
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",
|
|
@@ -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",
|
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
|
|
|
@@ -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
|
+
}
|