uniweb 0.82.2 → 0.84.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 +7 -7
- package/partials/agents.md +35 -14
- package/src/backend/client.js +84 -13
- package/src/backend/foundation-bring-along.js +81 -11
- package/src/backend/service-request.js +204 -173
- package/src/backend/site-sync.js +72 -3
- package/src/backend/workspace.js +52 -17
- package/src/commands/publish.js +39 -159
- package/src/commands/push.js +28 -4
- package/src/commands/register.js +64 -19
- package/src/commands/status.js +32 -3
- package/src/framework-index.json +6 -6
- package/src/index.js +28 -10
- package/src/utils/config.js +61 -6
- package/src/utils/registry-auth.js +18 -5
- package/src/utils/registry-orgs.js +43 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/content-writer": "^0.3.4",
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/kit": "^0.19.18",
|
|
46
|
+
"@uniweb/core": "^0.37.2",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.5.1",
|
|
48
|
+
"@uniweb/runtime": "^0.29.2",
|
|
49
|
+
"@uniweb/schemas": "^0.12.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
+
"@uniweb/build": "^0.77.0",
|
|
52
53
|
"@uniweb/content-reader": "^1.2.8",
|
|
53
|
-
"@uniweb/build": "^0.76.1",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.5.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
package/partials/agents.md
CHANGED
|
@@ -2012,16 +2012,16 @@ A form gets its destination from the first of these that applies:
|
|
|
2012
2012
|
|
|
2013
2013
|
1. **One the host supplies** — `services.submit` in the served payload. Where the
|
|
2014
2014
|
host handles submissions, that is the destination and nothing in `site.yml`
|
|
2015
|
-
overrides it — so a site published to Uniweb Cloud
|
|
2016
|
-
`
|
|
2015
|
+
overrides it — so a site published to Uniweb Cloud needs **no `submit:`**: it
|
|
2016
|
+
asks for form handling with `services:` instead (*Uniweb Cloud*, below).
|
|
2017
2017
|
2. **`submit:` in `site.yml`** — an endpoint you name yourself, for a host that
|
|
2018
2018
|
does not handle submissions, or a static site.
|
|
2019
|
-
3. **Neither** — there is no destination
|
|
2020
|
-
|
|
2019
|
+
3. **Neither** — there is no destination: render no form, or fall back to contact
|
|
2020
|
+
details the site already carries.
|
|
2021
2021
|
|
|
2022
|
-
That is the general arrangement, not a forms-only one. A host
|
|
2023
|
-
|
|
2024
|
-
by the same rule — the host's offer, then your declaration, then neither.
|
|
2022
|
+
That is the general arrangement, not a forms-only one. A host states everything
|
|
2023
|
+
it offers in the served payload's `services`, keyed by name, and every service
|
|
2024
|
+
resolves by the same rule — the host's offer, then your declaration, then neither.
|
|
2025
2025
|
|
|
2026
2026
|
⭐ **Before you render UI for a service, ask whether the site has it** — one predicate per service,
|
|
2027
2027
|
no arguments: `isSearchEnabled()`, `isSubmitEnabled()`, `isApiEnabled()`, `isAssistantEnabled()`,
|
|
@@ -2138,9 +2138,9 @@ if (!canSubmit) return null // nowhere to send — render no form, or fall ba
|
|
|
2138
2138
|
```
|
|
2139
2139
|
|
|
2140
2140
|
> **The framework never invents an endpoint — but a host may supply one.** Don't
|
|
2141
|
-
> reach for `submit:` reflexively: on Uniweb Cloud
|
|
2142
|
-
> it
|
|
2143
|
-
> one hosting.
|
|
2141
|
+
> reach for `submit:` reflexively: on Uniweb Cloud the host's form handling wins
|
|
2142
|
+
> wherever it is on, so a `submit:` there answers only when it is off — ask for it
|
|
2143
|
+
> with `services:` instead. Reach for `submit:` when you are the one hosting.
|
|
2144
2144
|
>
|
|
2145
2145
|
> `canSubmit` is false only when neither a declaration nor a host supplies a
|
|
2146
2146
|
> destination. **Check it when you render, not only on the button press** — a
|
|
@@ -2635,9 +2635,12 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2635
2635
|
> uniweb push --org @acme # this command only
|
|
2636
2636
|
> ```
|
|
2637
2637
|
>
|
|
2638
|
-
> Already logged in, `uniweb login --org @other`
|
|
2639
|
-
>
|
|
2640
|
-
>
|
|
2638
|
+
> Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
|
|
2639
|
+
> backend you are logged in to, without logging in again. **Without a
|
|
2640
|
+
> terminal — you, an agent — a login with organizations and no workspace named signs you in but
|
|
2641
|
+
> exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
|
|
2642
|
+
> Until then every command that works on a site refuses, and so does registering a foundation whose
|
|
2643
|
+
> name has no scope. A process logged in with `UNIWEB_TOKEN` names its workspace with
|
|
2641
2644
|
> `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
|
|
2642
2645
|
> picking for them — a site created in the wrong one cannot be moved from here.
|
|
2643
2646
|
>
|
|
@@ -2654,6 +2657,24 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2654
2657
|
|
|
2655
2658
|
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. Commit it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --backend <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --backend <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--backend` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
|
|
2656
2659
|
|
|
2660
|
+
**`services:` in `site.yml` asks your host for services** — site search, form handling, accounts:
|
|
2661
|
+
|
|
2662
|
+
```yaml
|
|
2663
|
+
services:
|
|
2664
|
+
search: true # turn it on
|
|
2665
|
+
submit: false # turn it off
|
|
2666
|
+
api: # turn it on, with the service's own settings
|
|
2667
|
+
grade: pro
|
|
2668
|
+
```
|
|
2669
|
+
|
|
2670
|
+
A service you leave out keeps whatever the site has, and settings you leave out keep theirs — to turn
|
|
2671
|
+
one off, say `false`. `uniweb push` and `uniweb publish` send what you changed since your last sync;
|
|
2672
|
+
if the site's services changed elsewhere in the meantime — an author in the app — they offer to update
|
|
2673
|
+
`site.yml` rather than send your older choice over it, and if both changed the same service they ask
|
|
2674
|
+
which to keep. `uniweb pull` writes what the site has into `services:`. A change that needs payment is
|
|
2675
|
+
settled in the app: `publish` opens it. ⛔ **Not the same as `search:` / `submit:`**, which configure a
|
|
2676
|
+
provider the site brings itself — and `services:` never reaches the built site.
|
|
2677
|
+
|
|
2657
2678
|
**The Cloud also provides a real backend for structured data:** a database for every registered data schema, and a CMS that edits both static page content and dynamic data entities typed by those schemas. That's the piece that makes it viable for teams and client work — the client manages records, not markdown files.
|
|
2658
2679
|
|
|
2659
2680
|
Either side can publish. Nothing about this changes how you build: the same foundation and the same site run under `uniweb dev`, `uniweb export`, or a CI deploy with no account at all.
|
|
@@ -2696,7 +2717,7 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
|
|
|
2696
2717
|
|---|---|---|---|
|
|
2697
2718
|
| `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
|
|
2698
2719
|
|
|
2699
|
-
**The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else
|
|
2720
|
+
**The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else the workspace you work in — an org's scope, or your personal one; when you belong to orgs and chose no workspace, it asks at a terminal and refuses without one — and writes it into the name; a `--scope` naming another scope than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
|
|
2700
2721
|
|
|
2701
2722
|
**Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
|
|
2702
2723
|
|
package/src/backend/client.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* single home.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { getRegistryApiBaseUrl } from '../utils/config.js'
|
|
32
|
+
import { getRegistryApiBaseUrl, loginCommand } from '../utils/config.js'
|
|
33
33
|
import {
|
|
34
34
|
ensureRegistryAuth,
|
|
35
35
|
fetchMe,
|
|
@@ -191,11 +191,12 @@ export function workspaceHeader(value) {
|
|
|
191
191
|
*/
|
|
192
192
|
export class WorkspaceMismatchError extends Error {
|
|
193
193
|
/**
|
|
194
|
-
* @param {{ named: string|null, answer: string|null, source?: string }} p -
|
|
195
|
-
* workspace the request named and the one the backend named (`@handle`, a unit's
|
|
196
|
-
* uuid, null for personal),
|
|
194
|
+
* @param {{ named: string|null, answer: string|null, source?: string, origin?: string|null }} p -
|
|
195
|
+
* the workspace the request named and the one the backend named (`@handle`, a unit's
|
|
196
|
+
* uuid, null for personal), where the named one came from (`workspace.js`), and the
|
|
197
|
+
* backend — a login that switches must name it unless it is the default (`loginCommand`)
|
|
197
198
|
*/
|
|
198
|
-
constructor({ named, answer, source = 'login' }) {
|
|
199
|
+
constructor({ named, answer, source = 'login', origin = null }) {
|
|
199
200
|
const where = (w) =>
|
|
200
201
|
!w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
|
|
201
202
|
// How to work in the site's workspace, said for the way this one was chosen.
|
|
@@ -204,7 +205,11 @@ export class WorkspaceMismatchError extends Error {
|
|
|
204
205
|
: answer.startsWith('@')
|
|
205
206
|
? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
|
|
206
207
|
: null
|
|
207
|
-
const login = !answer
|
|
208
|
+
const login = !answer
|
|
209
|
+
? `${loginCommand(origin)} --personal`
|
|
210
|
+
: answer.startsWith('@')
|
|
211
|
+
? `${loginCommand(origin)} --org ${answer}`
|
|
212
|
+
: null
|
|
208
213
|
const fix = !switchTo
|
|
209
214
|
? 'It has no handle, so the CLI cannot work in it.'
|
|
210
215
|
: source === 'flag'
|
|
@@ -332,12 +337,16 @@ export class BackendClient {
|
|
|
332
337
|
return this._token
|
|
333
338
|
}
|
|
334
339
|
// ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
|
|
335
|
-
// It existed because the session store held ONE record
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// (utils/registry-auth.js), so
|
|
339
|
-
//
|
|
340
|
-
//
|
|
340
|
+
// It existed because the session store held ONE record, read without an origin: a
|
|
341
|
+
// session for another backend was about to be sent here and rejected, and a warning
|
|
342
|
+
// was the best available move. The session is looked up BY ORIGIN now
|
|
343
|
+
// (`readRegistryAuth(origin)`, utils/registry-auth.js), so another backend's token is
|
|
344
|
+
// never sent here — `ensureRegistryAuth` below finds this origin's session or asks
|
|
345
|
+
// for one. ⚠️ There is still ONE session (since 2026-09-21): a login it asks for
|
|
346
|
+
// replaces the other backend's session, and says so. *(Until 2026-10-05 this read
|
|
347
|
+
// "sessions are keyed by origin now … being logged into another backend is just a
|
|
348
|
+
// fact about another backend" — true of the per-backend store of 2026-09-20→21, and
|
|
349
|
+
// read as "several sessions coexist".)*
|
|
341
350
|
this._token = await ensureRegistryAuth({
|
|
342
351
|
apiBase: this.origin,
|
|
343
352
|
command: this._command,
|
|
@@ -388,7 +397,8 @@ export class BackendClient {
|
|
|
388
397
|
throw new WorkspaceMismatchError({
|
|
389
398
|
named: this._workspace,
|
|
390
399
|
answer,
|
|
391
|
-
source: this._workspaceSource
|
|
400
|
+
source: this._workspaceSource,
|
|
401
|
+
origin: this.origin
|
|
392
402
|
})
|
|
393
403
|
}
|
|
394
404
|
|
|
@@ -809,6 +819,67 @@ export class BackendClient {
|
|
|
809
819
|
* @param {string} uuid - the site-content uuid
|
|
810
820
|
* @returns {Promise<object|null>}
|
|
811
821
|
*/
|
|
822
|
+
/**
|
|
823
|
+
* Whether the backend holds the site `uuid` names — `live`, `gone` or `unknown`, kept
|
|
824
|
+
* apart — for a script that must decide, before any push, whether a binding still
|
|
825
|
+
* points at a site (`uniweb status --remote --json`, `remote.site_state`).
|
|
826
|
+
*
|
|
827
|
+
* ⛔ `gone` ONLY ON THE BACKEND'S OWN WORD ABOUT THIS SITE: a `404` whose problem names
|
|
828
|
+
* it — `kind: "site"`, `key: <uuid>`. A push from an unbound copy CREATES a site, so a
|
|
829
|
+
* binding dropped because its site was merely unreadable leaves a second copy beside
|
|
830
|
+
* the first. Folding `unknown` into `gone` would be worse than no answer.
|
|
831
|
+
*
|
|
832
|
+
* live the site's status came back, or the site was refused as belonging to
|
|
833
|
+
* another workspace (`409 wrong_workspace` names where it is: it exists)
|
|
834
|
+
* gone a `404` naming this site
|
|
835
|
+
* unknown anything else — no answer, a credential or an id refused, a `404` that
|
|
836
|
+
* names something else (a backend too old for the route)
|
|
837
|
+
*
|
|
838
|
+
* *Measured 2026-10-06 against a local backend — its wire, its to change:* `200` with
|
|
839
|
+
* the status; `409 wrong_workspace` naming the site's workspace; `404 {kind: "site",
|
|
840
|
+
* key}`; `400` for a malformed id; `401` for a bad bearer.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} uuid
|
|
843
|
+
* @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, detail?: string|null }>}
|
|
844
|
+
*/
|
|
845
|
+
async siteState(uuid) {
|
|
846
|
+
let res
|
|
847
|
+
try {
|
|
848
|
+
res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (err instanceof WorkspaceMismatchError) {
|
|
851
|
+
return { state: 'live', status: 409, workspace: err.answer, detail: err.message }
|
|
852
|
+
}
|
|
853
|
+
return { state: 'unknown', status: null, detail: err?.message || String(err) }
|
|
854
|
+
}
|
|
855
|
+
const body = await res.text().catch(() => '')
|
|
856
|
+
let parsed = null
|
|
857
|
+
try {
|
|
858
|
+
parsed = body ? JSON.parse(body) : null
|
|
859
|
+
} catch {
|
|
860
|
+
parsed = null
|
|
861
|
+
}
|
|
862
|
+
if (res.ok) return { state: 'live', status: res.status, ...(parsed ? { site: parsed } : {}) }
|
|
863
|
+
if (res.status === 409 && parsed?.reason === 'wrong_workspace') {
|
|
864
|
+
const w = parsed.workspace || {}
|
|
865
|
+
return {
|
|
866
|
+
state: 'live',
|
|
867
|
+
status: 409,
|
|
868
|
+
workspace: w.handle ? workspaceHandle(w.handle) : w.unit_uuid || null,
|
|
869
|
+
detail: parsed.detail || null
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const names = (v) => String(v ?? '').toLowerCase() === String(uuid).toLowerCase()
|
|
873
|
+
if (res.status === 404 && parsed?.kind === 'site' && names(parsed.key)) {
|
|
874
|
+
return { state: 'gone', status: 404, detail: parsed.detail || null }
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
state: 'unknown',
|
|
878
|
+
status: res.status,
|
|
879
|
+
detail: parsed?.detail || parsed?.title || (body ? body.slice(0, 200) : null)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
812
883
|
async siteStatus(uuid) {
|
|
813
884
|
try {
|
|
814
885
|
const res = await this.request(
|
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
} from '@uniweb/build'
|
|
64
64
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
65
65
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
66
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
67
|
+
import { belongsToScope } from '../utils/registry-orgs.js'
|
|
66
68
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
67
69
|
import {
|
|
68
70
|
compareSemverPrecedence,
|
|
@@ -191,13 +193,22 @@ function writePkgVersion(dir, version) {
|
|
|
191
193
|
writeJsonPreservingStyle(path, { ...JSON.parse(src), version }, src)
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
// What travels to the spawned `uniweb register`
|
|
195
|
-
//
|
|
196
|
-
// (releaseFoundation), and the SESSION is the shared session
|
|
197
|
-
// which the child inherits. The backend commands take no
|
|
198
|
-
|
|
196
|
+
// What travels to the spawned `uniweb register` on the command line: --non-interactive,
|
|
197
|
+
// and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
|
|
198
|
+
// the child's environment (releaseFoundation), and the SESSION is the shared session
|
|
199
|
+
// file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
|
|
200
|
+
// `--backend` or `--token`.
|
|
201
|
+
//
|
|
202
|
+
// ⭐ The workspace travels because a bare name registers under the workspace the command
|
|
203
|
+
// works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
|
|
204
|
+
// alone: the child cannot read it from the session. One chosen at the login, or in
|
|
205
|
+
// UNIWEB_WORKSPACE, reaches the child on its own.
|
|
206
|
+
export function forwardedFlags(args) {
|
|
199
207
|
const out = []
|
|
200
208
|
if (isNonInteractive(args)) out.push('--non-interactive')
|
|
209
|
+
const org = readOrgFlag(args)
|
|
210
|
+
if (org) out.push('--org', org)
|
|
211
|
+
else if (args.includes('--personal')) out.push('--personal')
|
|
201
212
|
return out
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -280,9 +291,16 @@ async function bringLocalCodeAlong({
|
|
|
280
291
|
}) {
|
|
281
292
|
const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
|
|
282
293
|
const scopedName = await foundationScopedName(local.dir)
|
|
294
|
+
// A name with no scope yet (it has never registered) is still a name: say it. ⛔ Until
|
|
295
|
+
// 2026-10-06 the label fell back to the KIND, and the line read "Releasing the foundation
|
|
296
|
+
// foundation@0.1.0".
|
|
297
|
+
const bareName = scopedName
|
|
298
|
+
? null
|
|
299
|
+
: await readFoundationName(local.dir).then((r) => (r?.name && !checkFoundationName(r.name) ? r.name : null), () => null)
|
|
300
|
+
const shown = scopedName || bareName
|
|
283
301
|
const label =
|
|
284
|
-
|
|
285
|
-
? `${
|
|
302
|
+
shown || local.version
|
|
303
|
+
? `${shown || kind}${local.version ? `@${local.version}` : ''}`
|
|
286
304
|
: `the local ${kind}`
|
|
287
305
|
const skipPrompts =
|
|
288
306
|
args.includes('--yes') ||
|
|
@@ -361,6 +379,16 @@ async function bringLocalCodeAlong({
|
|
|
361
379
|
// answer / no scoped name to look up) → release.
|
|
362
380
|
const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
|
|
363
381
|
|
|
382
|
+
// Every release below goes through here, so a refusal for a scope you are not a member
|
|
383
|
+
// of is said as that, with the ways on (`explainReleaseFailure`).
|
|
384
|
+
const release = async () => {
|
|
385
|
+
try {
|
|
386
|
+
return releaseFoundation(local, args, cliBin, say, client?.origin)
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw await explainReleaseFailure(err, { client, local, reg, verb })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
364
392
|
if (!reg) {
|
|
365
393
|
// ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
|
|
366
394
|
// and shipping anyway would leave a site the app cannot open — so stop and say so.
|
|
@@ -376,7 +404,7 @@ async function bringLocalCodeAlong({
|
|
|
376
404
|
}
|
|
377
405
|
say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
|
|
378
406
|
return {
|
|
379
|
-
released:
|
|
407
|
+
released: await release(),
|
|
380
408
|
proceed: true,
|
|
381
409
|
ref: await pinnedRef()
|
|
382
410
|
}
|
|
@@ -406,7 +434,7 @@ async function bringLocalCodeAlong({
|
|
|
406
434
|
}
|
|
407
435
|
writePkgVersion(local.dir, bumpTo)
|
|
408
436
|
say.info(`Releasing the ${kind} ${scopedName || kind} as ${bumpTo} — ${why}…`)
|
|
409
|
-
const released =
|
|
437
|
+
const released = await release()
|
|
410
438
|
say.dim(`The ${kind}'s package.json now says ${bumpTo} — commit it.`)
|
|
411
439
|
if (optOut) say.dim('To send content without releasing code, pass `--no-release`.')
|
|
412
440
|
return { released, proceed: true, bumped: bumpTo, ref: await pinnedRef() }
|
|
@@ -480,7 +508,7 @@ async function bringLocalCodeAlong({
|
|
|
480
508
|
: `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
|
|
481
509
|
)
|
|
482
510
|
return {
|
|
483
|
-
released:
|
|
511
|
+
released: await release(),
|
|
484
512
|
proceed: true,
|
|
485
513
|
ref: await pinnedRef()
|
|
486
514
|
}
|
|
@@ -514,7 +542,7 @@ async function bringLocalCodeAlong({
|
|
|
514
542
|
)
|
|
515
543
|
if (reRelease)
|
|
516
544
|
return {
|
|
517
|
-
released:
|
|
545
|
+
released: await release(),
|
|
518
546
|
proceed: true,
|
|
519
547
|
ref: await pinnedRef()
|
|
520
548
|
}
|
|
@@ -570,6 +598,48 @@ function releaseFoundation(local, args, cliBin, say, origin) {
|
|
|
570
598
|
return true
|
|
571
599
|
}
|
|
572
600
|
|
|
601
|
+
/**
|
|
602
|
+
* A failed release, explained when the cause is a scope you are not a member of
|
|
603
|
+
* *[Diego, 2026-10-06]* — or the error as it came.
|
|
604
|
+
*
|
|
605
|
+
* The scope is the one in the foundation's name: `register` writes a bare name's scope
|
|
606
|
+
* before it submits, so a refused release leaves the name scoped. When the orgs read
|
|
607
|
+
* says the account is neither that scope's owner nor a member of its org, the error
|
|
608
|
+
* says so (`notMember`) and carries the ways on (`ways`): `--no-release`, where a
|
|
609
|
+
* released version exists to send the content against, and a member releasing it.
|
|
610
|
+
* Its message is what the refusal means for the push — nothing was sent — since the
|
|
611
|
+
* `register` it ran has just printed the refusal itself, membership and the registry's
|
|
612
|
+
* own sentence included (measured against a local backend, 2026-10-06: HTTP 400, no
|
|
613
|
+
* typed reason).
|
|
614
|
+
*
|
|
615
|
+
* ⛔ The registry decides membership; this only names it after a refusal, and passes
|
|
616
|
+
* the error through whenever it cannot tell.
|
|
617
|
+
*
|
|
618
|
+
* @param {Error} err - the release's failure
|
|
619
|
+
* @param {object} o
|
|
620
|
+
* @param {import('./client.js').BackendClient} o.client
|
|
621
|
+
* @param {{ dir: string }} o.local
|
|
622
|
+
* @param {{ latest_version?: string }|null} o.reg - the catalog's answer for this name
|
|
623
|
+
* @param {string} o.verb - `push` or `publish`, for the ways on
|
|
624
|
+
* @returns {Promise<Error>}
|
|
625
|
+
*/
|
|
626
|
+
export async function explainReleaseFailure(err, { client, local, reg, verb }) {
|
|
627
|
+
const name = await foundationScopedName(local.dir).catch(() => null)
|
|
628
|
+
const scope = name ? name.split('/')[0] : null
|
|
629
|
+
if (!scope || typeof client?.fetchOrgs !== 'function') return err
|
|
630
|
+
const member = belongsToScope(scope, await client.fetchOrgs().catch(() => null))
|
|
631
|
+
if (member !== false) return err
|
|
632
|
+
const explained = new Error(`${name} was not released, so nothing was sent.`)
|
|
633
|
+
explained.notMember = true
|
|
634
|
+
explained.ways = reg?.latest_version
|
|
635
|
+
? [
|
|
636
|
+
`Send the content against the released ${reg.latest_version}: \`uniweb ${verb} --no-release\`.`,
|
|
637
|
+
`Or ask a member of ${scope} to release it.`
|
|
638
|
+
]
|
|
639
|
+
: [`Ask a member of ${scope} to release it.`]
|
|
640
|
+
return explained
|
|
641
|
+
}
|
|
642
|
+
|
|
573
643
|
/**
|
|
574
644
|
* Bring the site's LOCAL extensions along — the exact parallel of
|
|
575
645
|
* `bringFoundationAlong`, run for each workspace-local extension.
|