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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.82.2",
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/core": "^0.37.1",
46
- "@uniweb/schemas": "^0.12.0",
47
- "@uniweb/runtime": "^0.29.1",
48
- "@uniweb/kit": "^0.19.17",
49
- "@uniweb/semantic-parser": "^1.5.1"
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": {
@@ -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 normally needs **no
2016
- `submit:` at all**.
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, and the form says so instead of
2020
- guessing at one.
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 declares
2023
- everything it offers under `services`, keyed by name, and every service resolves
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 it is redundant, and setting
2142
- > it there overrides what the platform provides. Reach for it when you are the
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` switches without logging in again. **Without a
2639
- > terminal — you, an agent — a login with organizations refuses until one is named**, and so
2640
- > does a command when none was. A process logged in with `UNIWEB_TOKEN` names its workspace with
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 your personal scope (asking only if you belong to orgs), 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`.)
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
 
@@ -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 - the
195
- * workspace the request named and the one the backend named (`@handle`, a unit's
196
- * uuid, null for personal), and where the named one came from (`workspace.js`)
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 ? 'uniweb login --personal' : answer.startsWith('@') ? `uniweb login --org ${answer}` : null
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: a session for another
336
- // backend was the only session, so it was about to be sent here and rejected, and a
337
- // warning was the best available move. Sessions are keyed by origin now
338
- // (utils/registry-auth.js), so being logged into another backend is just a fact
339
- // about another backend — `ensureRegistryAuth` below finds this origin's session or
340
- // asks for one. Warning about the other would be noise about nothing.
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` / `build` on the command line: only
195
- // --non-interactive. The BACKEND travels in the child's environment
196
- // (releaseFoundation), and the SESSION is the shared session file — or UNIWEB_TOKEN,
197
- // which the child inherits. The backend commands take no `--backend` or `--token`.
198
- function forwardedFlags(args) {
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
- scopedName || local.version
285
- ? `${scopedName || kind}${local.version ? `@${local.version}` : ''}`
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: releaseFoundation(local, args, cliBin, say, client?.origin),
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 = releaseFoundation(local, args, cliBin, say, client?.origin)
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: releaseFoundation(local, args, cliBin, say, client?.origin),
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: releaseFoundation(local, args, cliBin, say, client?.origin),
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.