uniweb 0.82.2 → 0.83.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.83.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
45
  "@uniweb/runtime": "^0.29.1",
48
46
  "@uniweb/kit": "^0.19.17",
47
+ "@uniweb/schemas": "^0.12.0",
48
+ "@uniweb/core": "^0.37.1",
49
49
  "@uniweb/semantic-parser": "^1.5.1"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/content-reader": "^1.2.8",
53
52
  "@uniweb/build": "^0.76.1",
53
+ "@uniweb/content-reader": "^1.2.8",
54
54
  "@uniweb/semantic-parser": "^1.5.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -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
  >
@@ -2696,7 +2699,7 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2696
2699
  |---|---|---|---|
2697
2700
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2698
2701
 
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`.)
2702
+ **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
2703
 
2701
2704
  **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
2705
 
@@ -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.
@@ -1109,8 +1109,8 @@ function recordSiteWorkspace(siteDir, backend, owner) {
1109
1109
  * ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
1110
1110
  * allowance belongs to the site's owner, who may not be the person pushing. Here
1111
1111
  * there is no site yet, so the only workspace in play is the one the site would be
1112
- * created in — `--as <org>` included. Saying "the site owner's workspace" would be
1113
- * incoherent for a site that does not exist.
1112
+ * created in — one named with `--org` included. Saying "the site owner's workspace"
1113
+ * would be incoherent for a site that does not exist.
1114
1114
  *
1115
1115
  * @param {string} body - the raw response body
1116
1116
  * @returns {string|null}
@@ -1125,6 +1125,9 @@ function describeCreateRefusal(body) {
1125
1125
  }
1126
1126
  if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
1127
1127
 
1128
+ const attach = describeAttachRefusal(p)
1129
+ if (attach) return [attach.headline, ...attach.steps].join('\n ')
1130
+
1128
1131
  if (p.reason === 'storage_quota_exceeded') {
1129
1132
  const parts = []
1130
1133
  const used = humanBytes(p.used_bytes)
@@ -1144,6 +1147,63 @@ function describeCreateRefusal(body) {
1144
1147
  return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
1145
1148
  }
1146
1149
 
1150
+ /**
1151
+ * The backend's refusals to ATTACH a foundation to a site, as what happened and what to
1152
+ * do next — or null for any other problem document.
1153
+ *
1154
+ * A site takes any version of a foundation it already uses, and a new one only from a
1155
+ * scope its author owns. The create, the first push and a push that changes the site's
1156
+ * foundation can each be refused:
1157
+ *
1158
+ * `foundation_not_licensed` not yours to attach. `templates` names the app
1159
+ * templates that carry it: start from one in the app,
1160
+ * then clone the site you made. With none, register a
1161
+ * foundation under a scope you own.
1162
+ * `foundation_not_registered` this backend has no such version.
1163
+ *
1164
+ * ⛔ Branch on `reason`, never on the status: a `403` is also a rejected credential, and
1165
+ * "log in again" is the wrong advice for a foundation that is not yours. ⚠️ Until
1166
+ * 2026-10-06 a push printed exactly that for this refusal, and the create printed the
1167
+ * reason's bare name.
1168
+ *
1169
+ * @param {object|null} problem - a parsed problem document
1170
+ * @returns {{ headline: string, steps: string[] } | null}
1171
+ */
1172
+ export function describeAttachRefusal(problem) {
1173
+ if (!problem || typeof problem !== 'object') return null
1174
+ const named = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
1175
+
1176
+ if (problem.reason === 'foundation_not_licensed') {
1177
+ const pkg = named(problem.package) || 'This foundation'
1178
+ const templates = (Array.isArray(problem.templates) ? problem.templates : [])
1179
+ .map((t) => named(t?.name))
1180
+ .filter(Boolean)
1181
+ const SHOWN = 5
1182
+ const list =
1183
+ templates
1184
+ .slice(0, SHOWN)
1185
+ .map((n) => `“${n}”`)
1186
+ .join(', ') + (templates.length > SHOWN ? ` and ${templates.length - SHOWN} more` : '')
1187
+ const steps = templates.length
1188
+ ? [
1189
+ `Start from ${templates.length === 1 ? list : `one of ${list}`} in the app — ` +
1190
+ `${templates.length === 1 ? 'that template carries' : 'those templates carry'} it — ` +
1191
+ "then `uniweb clone <your site's uuid>`."
1192
+ ]
1193
+ : ['Register a foundation under a scope you own, and name it in site.yml.']
1194
+ return { headline: `${pkg} is not yours to attach.`, steps }
1195
+ }
1196
+
1197
+ if (problem.reason === 'foundation_not_registered') {
1198
+ const f = named(problem.foundation) || 'The foundation this site names'
1199
+ return {
1200
+ headline: `${f} is not registered on this backend.`,
1201
+ steps: ['Register it here first (`uniweb register` in its directory), or name a version this backend has.']
1202
+ }
1203
+ }
1204
+ return null
1205
+ }
1206
+
1147
1207
  /**
1148
1208
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
1149
1209
  *
@@ -1759,13 +1819,22 @@ export async function pushSyncPackages({
1759
1819
  // Two unrelated conflicts share HTTP 409, so branch on the machine-readable
1760
1820
  // `reason` — never on `detail`, which is prose the backend may reword.
1761
1821
  let problem = null
1762
- if ((res.status === 409 || res.status === 400 || res.status === 422) && body) {
1822
+ if ((res.status === 409 || res.status === 400 || res.status === 403 || res.status === 422) && body) {
1763
1823
  try {
1764
1824
  problem = JSON.parse(body)
1765
1825
  } catch {
1766
1826
  /* not a problem document */
1767
1827
  }
1768
1828
  }
1829
+ // A foundation the site may not take (`foundation_not_licensed`, a 403) or that this
1830
+ // backend does not have (`foundation_not_registered`) — said, with what to do next.
1831
+ // Before the credential branch below, which a 403 would otherwise reach.
1832
+ const attach = describeAttachRefusal(problem)
1833
+ if (attach) {
1834
+ error(`${label} push refused — ${attach.headline}`)
1835
+ for (const step of attach.steps) note(step)
1836
+ return null
1837
+ }
1769
1838
  // The package carried no identity for records the backend already stores, so
1770
1839
  // applying it would replace every one of them. `ensureItemUuids` is supposed
1771
1840
  // to make this unreachable, so reaching it means that recovery failed — say so
@@ -26,7 +26,8 @@
26
26
  */
27
27
 
28
28
  import { readOrgFlag } from '../utils/args.js'
29
- import { fetchOrgs } from '../utils/registry-orgs.js'
29
+ import { loginCommand } from '../utils/config.js'
30
+ import { fetchOrgs, bareHandle, validateHandle } from '../utils/registry-orgs.js'
30
31
  import { readRegistryAuth } from '../utils/registry-auth.js'
31
32
  import { workspaceHandle, describeWorkspace } from './client.js'
32
33
 
@@ -65,12 +66,14 @@ export const SOURCE_LABEL = {
65
66
  offline: 'not resolved in a preview'
66
67
  }
67
68
 
68
- const CHOOSE = [
69
- 'Choose the workspace you work in:',
70
- ' uniweb login --org @acme (or --personal) — for every command after',
71
- ' --org @acme / --personal — for this command only',
72
- ` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
73
- ].join('\n')
69
+ /** How to choose, for a command on `origin` — its login names the backend when it must (`loginCommand`). */
70
+ const choose = (origin) =>
71
+ [
72
+ 'Choose the workspace you work in:',
73
+ ` ${loginCommand(origin)} --org @acme (or --personal) — for every command after`,
74
+ ' --org @acme / --personal — for this command only',
75
+ ` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
76
+ ].join('\n')
74
77
 
75
78
  /**
76
79
  * The workspace this command works in.
@@ -119,10 +122,38 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
119
122
  if (!orgs.length) return { workspace: null, source: 'personal' }
120
123
  return {
121
124
  refused: true,
122
- reason: `You belong to organizations, so no workspace is assumed.\n ${CHOOSE}`
125
+ reason: `You belong to organizations, so no workspace is assumed.\n ${choose(client.origin)}`
123
126
  }
124
127
  }
125
128
 
129
+ /**
130
+ * The scope a bare name registers under by default — the workspace the command works in
131
+ * *[Diego, 2026-10-06]*: an organization's handle, or your own handle for your personal
132
+ * workspace.
133
+ *
134
+ * ⭐ A default, asked once: a name that carries a scope keeps it, and `--scope` names
135
+ * another. The workspace still never decides anything else about a foundation — it
136
+ * decides which SITE a command works on; here it only answers the question a bare name
137
+ * asks the first time it registers.
138
+ *
139
+ * Null when the workspace names no scope, and the caller derives one as before
140
+ * (`deriveScope`): none is chosen (you belong to organizations and none is named), the
141
+ * command is a preview, the workspace is a unit without a handle, or the account has no
142
+ * handle (a service account).
143
+ *
144
+ * @param {{ workspace?: string|null, source?: string, refused?: boolean }} ws -
145
+ * `resolveWorkspace`'s answer
146
+ * @param {string|null} accountHandle - the account's own handle (`GET /dev/orgs`), read
147
+ * only for the personal workspace
148
+ * @returns {string|null} `@handle`
149
+ */
150
+ export function scopeOfWorkspace(ws, accountHandle) {
151
+ if (!ws || ws.refused || ws.source === 'offline') return null
152
+ if (typeof ws.workspace === 'string') return ws.workspace.startsWith('@') ? ws.workspace : null
153
+ const h = bareHandle(accountHandle || '')
154
+ return h && !validateHandle(h) ? `@${h}` : null
155
+ }
156
+
126
157
  /**
127
158
  * The workspace a login works in — asked once, at `uniweb login`, and stored with the
128
159
  * session.
@@ -166,11 +197,17 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
166
197
  }
167
198
  }
168
199
 
169
- const { isNonInteractive } = await import('../utils/interactive.js')
200
+ const { isNonInteractive, getCliPrefix } = await import('../utils/interactive.js')
201
+ // ⭐ How to choose, said whole: the session is stored already (`registry-auth.js`, the
202
+ // login methods), so the login that finishes this signs nobody in again.
203
+ const others = [...mine.slice(1).map((h) => `--org ${h}`), '--personal']
204
+ const howToChoose =
205
+ `Choose one: ${loginCommand(apiBase, getCliPrefix())} --org ${mine[0]} (or ${others.join(', ')}) — ` +
206
+ 'it will not ask you to sign in again.'
170
207
  if (isNonInteractive(args)) {
171
208
  return {
172
209
  refused: true,
173
- reason: `You belong to organizations — name the workspace you work in: ${[...mine, '--personal'].map((w) => (w.startsWith('@') ? `--org ${w}` : w)).join(' | ')}.`
210
+ reason: `You belong to organizations, so no workspace is assumed. ${howToChoose}`
174
211
  }
175
212
  }
176
213
  const prompts = (await import('prompts')).default
@@ -188,12 +225,10 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
188
225
  ],
189
226
  initial: 0
190
227
  },
191
- {
192
- onCancel: () => {
193
- console.error('\nCancelled.')
194
- process.exit(0)
195
- }
196
- }
228
+ // ⛔ A cancelled pick is NOT a cancelled login: the session is stored already, so the
229
+ // login says so and how to finish it (`finishLogin`), and exits 2. Until 2026-10-06 this
230
+ // printed "Cancelled." and exited 0 — with the new session in place of the old one.
231
+ { onCancel: () => false }
197
232
  )
198
- return choice ? { choice } : { refused: true, reason: 'No workspace chosen.' }
233
+ return choice ? { choice } : { refused: true, reason: `No workspace chosen. ${howToChoose}` }
199
234
  }
@@ -492,8 +492,9 @@ export async function publish(args = []) {
492
492
  cliBin: process.argv[1]
493
493
  })
494
494
  } catch (err) {
495
- say.err(`Foundation release failed: ${err.message}`)
496
- say.dim('Fix the foundation, then re-run `uniweb publish`.')
495
+ // A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
496
+ say.err(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
497
+ for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb publish`.']) say.dim(line)
497
498
  return { exitCode: 1 }
498
499
  }
499
500
 
@@ -512,8 +513,8 @@ export async function publish(args = []) {
512
513
  cliBin: process.argv[1]
513
514
  })
514
515
  } catch (err) {
515
- say.err(`Extension release failed: ${err.message}`)
516
- say.dim('Fix the extension, then re-run `uniweb publish`.')
516
+ say.err(err.notMember ? err.message : `Extension release failed: ${err.message}`)
517
+ for (const line of err.notMember ? err.ways : ['Fix the extension, then re-run `uniweb publish`.']) say.dim(line)
517
518
  return { exitCode: 1 }
518
519
  }
519
520
  if (!ext.proceed) return { exitCode: 1 }
@@ -37,6 +37,9 @@
37
37
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
38
38
  * uniweb push --all Send every record (bypass the changed-only cache)
39
39
  * uniweb push --force Overwrite upstream changes (drop the staleness gate)
40
+ * uniweb push --no-release Send content against the foundation already
41
+ * released; release nothing
42
+ * uniweb push --bump Release above a newer registered version
40
43
  *
41
44
  * Pushes are GATED by default: each entity carries the backend `version` this clone
42
45
  * last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
@@ -274,8 +277,9 @@ export async function push(args = [], deps = {}) {
274
277
  verb: 'push'
275
278
  })
276
279
  } catch (err) {
277
- error(`Foundation release failed: ${err.message}`)
278
- note('Fix the foundation, then re-run `uniweb push`.')
280
+ // A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
281
+ error(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
282
+ for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb push`.']) note(line)
279
283
  return { exitCode: 1 }
280
284
  }
281
285
  if (!fnd.proceed) return { exitCode: 1 }
@@ -88,8 +88,9 @@ import {
88
88
  computeFoundationDigest,
89
89
  readRuntimePin
90
90
  } from '../utils/code-upload.js'
91
- import { deriveScope, publishScope } from '../utils/registry-orgs.js'
91
+ import { deriveScope, publishScope, belongsToScope } from '../utils/registry-orgs.js'
92
92
  import { BackendClient } from '../backend/client.js'
93
+ import { resolveWorkspace, scopeOfWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
93
94
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
94
95
  import {
95
96
  findWorkspaceRoot,
@@ -316,8 +317,10 @@ export async function settleFoundationName(targetDir, { args, isPreview }) {
316
317
  * A preview (`--dry-run`, `-o`) writes nothing: a bare name previews under `--scope`,
317
318
  * or unscoped.
318
319
  *
319
- * @returns {Promise<{ scope: string|null, source: string|null } | { cancelled: true } | null>}
320
- * null when refused (the reason is printed); `cancelled` when no org was chosen
320
+ * @returns {Promise<{ scope: string|null, source: string|null } | null>}
321
+ * null when refused, the reason printed — no scope chosen included, since a name with
322
+ * no scope cannot register. ⛔ Until 2026-10-06 "no scope chosen" was `cancelled`, and
323
+ * register exited 0 having registered nothing.
321
324
  */
322
325
  export async function settleFoundationScope(targetDir, { args, isPreview, flagScope, client }) {
323
326
  let read
@@ -351,7 +354,7 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
351
354
  let source = flagScope ? '--scope' : null
352
355
  if (!scope) {
353
356
  scope = await deriveScopeFromLogin(client, args)
354
- if (!scope) return { cancelled: true }
357
+ if (!scope) return null
355
358
  source = 'login'
356
359
  }
357
360
 
@@ -375,11 +378,35 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
375
378
  }
376
379
 
377
380
  /**
378
- * A scope derived from the login (`deriveScope`): your personal scope when you belong
379
- * to no org, else a pick — as `@handle`, or null when none was chosen.
381
+ * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
382
+ * when none was chosen.
383
+ *
384
+ * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
385
+ * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
386
+ * working in `@acme` registers `@acme/…`, which any of its members can release.
387
+ * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
388
+ * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
389
+ * renaming it into a new foundation.
390
+ *
391
+ * When the workspace names no scope — none is chosen, or it is a unit without a handle —
392
+ * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
393
+ * you belong to none, else a pick at a terminal, your personal scope without one.
380
394
  */
381
395
  async function deriveScopeFromLogin(client, args) {
382
396
  const token = await client.token()
397
+ const ws = await resolveWorkspace({ client, args })
398
+ const personal = !ws.refused && ws.source !== 'offline' && ws.workspace === null
399
+ const accountHandle = personal ? (await client.fetchOrgs()).account_handle : null
400
+ const fromWorkspace = scopeOfWorkspace(ws, accountHandle)
401
+ if (fromWorkspace) {
402
+ const named = `${colors.bright}${fromWorkspace}${colors.reset}`
403
+ console.error(
404
+ personal
405
+ ? `Registering under your personal scope ${named} — you work in your personal workspace (${SOURCE_LABEL[ws.source]}). For an org: --scope @org.`
406
+ : `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
407
+ )
408
+ return fromWorkspace
409
+ }
383
410
  const sess = await readRegistryAuth(client.origin)
384
411
  const derived = await deriveScope({
385
412
  apiBase: client.origin,
@@ -585,11 +612,13 @@ async function runRegister(args = []) {
585
612
  : await resolveFoundationDir(args)
586
613
 
587
614
  // Scope. ⭐ A FOUNDATION's is the one in its name (`name: '@acme/marketing'` in
588
- // main.js), settled below before the build; a bare name takes --scope, else one
589
- // derived from your orgs, and register writes it into the name (2026-09-22).
590
- // A SCHEMAS-ONLY package has no name to carry one: --scope, else package.json
591
- // `uniweb.scope`, else (real submit only) derived from login membership. Either
592
- // spelling, `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
615
+ // main.js), settled below before the build; a bare name takes --scope, else the
616
+ // workspace the command works in (`deriveScopeFromLogin`), and register writes it
617
+ // into the name (2026-09-22). A SCHEMAS-ONLY package has no name to carry one:
618
+ // --scope, else package.json `uniweb.scope`, else (real submit only) the workspace
619
+ // the command works in. Either spelling, `@acme` or `acme`, and from here on the one
620
+ // form: `@acme` (publishScope). ⛔ This said "derived from your orgs" / "from login
621
+ // membership" until 2026-10-06, read as the personal-scope default that was replaced.
593
622
  const pkgScope = standalone ? readPkgScope(targetDir) : null
594
623
  const givenScope = scopeFlag || pkgScope
595
624
  let scope = publishScope(givenScope)
@@ -640,7 +669,6 @@ async function runRegister(args = []) {
640
669
  client
641
670
  })
642
671
  if (!settled) return { exitCode: 2 }
643
- if (settled.cancelled) return { exitCode: 0 }
644
672
  scope = settled.scope
645
673
  scopeSource = settled.source
646
674
  // Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
@@ -691,12 +719,12 @@ async function runRegister(args = []) {
691
719
  }
692
720
  }
693
721
 
694
- // A schemas-only package with no scope, on a real submit → derive it from the login
695
- // (your personal scope, or a pick among it and your orgs) and persist it to package.json.
722
+ // A schemas-only package with no scope, on a real submit → the workspace you work in,
723
+ // else derived from your orgs (`deriveScopeFromLogin`), and persisted to package.json.
696
724
  // (A foundation's was settled before its build — `settleFoundationScope`.)
697
725
  if (standalone && !scope && !isPreview) {
698
726
  const derived = await deriveScopeFromLogin(client, args)
699
- if (!derived) return { exitCode: 0 }
727
+ if (!derived) return { exitCode: 2 }
700
728
  scope = derived
701
729
  scopeSource = 'login'
702
730
  try {
@@ -852,10 +880,27 @@ async function runRegister(args = []) {
852
880
  `${colors.dim}Schema for this version is already registered — resuming code delivery.${colors.reset}`
853
881
  )
854
882
  } else {
855
- error(
856
- `Registry rejected the submission: HTTP ${res.status} ${res.statusText}`
857
- )
858
- if (res.status === 401 || res.status === 403) {
883
+ // ⭐ NOT A MEMBER OF THE SCOPE IS SAID AS THAT *[Diego, 2026-10-06]*. The registry
884
+ // decides who may release into a scope — its account, or an org's members — and
885
+ // when the orgs read says you are neither, that is the refusal to name. ⛔ Until then
886
+ // a 403 here printed "log in again", the wrong advice for a scope that is not yours.
887
+ // Read to explain a refusal, never to gate one; a 401 is the credential itself.
888
+ const member =
889
+ res.status === 401 ? null : belongsToScope(scope, await client.fetchOrgs().catch(() => null))
890
+ if (member === false) {
891
+ const what = standalone
892
+ ? `data schemas under ${scope}`
893
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info?.name || `a foundation under ${scope}`
894
+ error(`You can't release ${what}: you're not a member of ${scope}.`)
895
+ log(
896
+ ` ${colors.dim}A member of ${scope} can release it. (The registry answered HTTP ${res.status}.)${colors.reset}`
897
+ )
898
+ } else {
899
+ error(
900
+ `Registry rejected the submission: HTTP ${res.status} ${res.statusText}`
901
+ )
902
+ }
903
+ if (member !== false && (res.status === 401 || res.status === 403)) {
859
904
  log(
860
905
  ` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
861
906
  )
@@ -14,8 +14,11 @@
14
14
  *
15
15
  * Usage:
16
16
  * uniweb status Sync identity + unpushed content + foundation ref (local)
17
- * uniweb status --remote Also: draft-vs-live + a newer-registered-foundation check
18
- * uniweb status --json One JSON line (adds a `remote` object under --remote)
17
+ * uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
18
+ * and a newer-registered-foundation check
19
+ * uniweb status --json One JSON line (adds a `remote` object under --remote, whose
20
+ * `site_state` is live / gone / unknown — `gone` only on the
21
+ * backend's own word that it holds no such site)
19
22
  *
20
23
  * Run from a site, or a workspace with one site.
21
24
  */
@@ -119,6 +122,9 @@ export async function status(args = []) {
119
122
  // Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
120
123
  // on 404 / any failure, so a backend without the endpoints just shows local.
121
124
  let site = null
125
+ // Whether the backend holds the site this binding names — live / gone / unknown, never
126
+ // folded (`client.siteState`). Null when there is no binding to ask about.
127
+ let siteState = null
122
128
  let fdnLatest = null
123
129
  let foundationFresh = null // true/false when both digests are known; else null
124
130
  let localFoundationVersion = null
@@ -134,7 +140,14 @@ export async function status(args = []) {
134
140
  const ws = await resolveWorkspace({ client, args })
135
141
  if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
136
142
  client.setWorkspace(ws.workspace, { source: ws.source })
137
- if (uuid) site = await client.siteStatus(uuid)
143
+ if (uuid) {
144
+ siteState = await client.siteState(uuid)
145
+ if (siteState.state === 'live') {
146
+ site = siteState.site || null
147
+ // In another workspace: it exists, and the refusal says where — report it.
148
+ if (siteState.status === 409) remoteError = siteState.detail
149
+ }
150
+ }
138
151
  // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
139
152
  // local-foundation site can be checked too); fall back to a scoped
140
153
  // site.yml ref. The digest compare is read-only — it never builds, so it
@@ -153,6 +166,8 @@ export async function status(args = []) {
153
166
  // backend does not work on this site from, or the deployment has another. Saying
154
167
  // nothing would read as "fine".
155
168
  if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
169
+ // Not asked, or not answered: that is not knowing, never `gone`.
170
+ if (uuid && !siteState) siteState = { state: 'unknown', status: null, detail: err?.message || null }
156
171
  }
157
172
  }
158
173
 
@@ -168,6 +183,10 @@ export async function status(args = []) {
168
183
  ...(remote
169
184
  ? {
170
185
  remote: {
186
+ // ⭐ live · gone · unknown — `gone` only on the backend's own word about
187
+ // this site; null when this directory names no site on this backend.
188
+ site_state: siteState ? siteState.state : null,
189
+ site_state_detail: siteState?.detail ?? null,
171
190
  site,
172
191
  foundation_latest: fdnLatest?.latest_version ?? null,
173
192
  foundation_fresh: foundationFresh,
@@ -221,6 +240,16 @@ export async function status(args = []) {
221
240
  // Remote signals
222
241
  if (remote) {
223
242
  if (remoteError) say.warn(remoteError)
243
+ if (siteState?.state === 'gone') {
244
+ say.warn(`The backend has no site ${uuid} — it was deleted there, or the backend was rebuilt.`)
245
+ say.dim(
246
+ `To push this as a new site: uniweb forget --backend ${probeBackend}, then uniweb push.`
247
+ )
248
+ } else if (siteState?.state === 'unknown') {
249
+ say.dim(
250
+ `Could not tell whether the backend holds this site${siteState.detail ? ` (${siteState.detail})` : ''}.`
251
+ )
252
+ }
224
253
  if (site) {
225
254
  if (site.draft_dirty) {
226
255
  say.info(
package/src/index.js CHANGED
@@ -917,10 +917,12 @@ async function main() {
917
917
  // Handle login command — the backend (username/password · paste a token ·
918
918
  // --token <bearer>). ⭐ `--backend <url>` names it; without the flag it is the DEFAULT
919
919
  // backend — UNIWEB_REGISTER_URL, else ~/.uniweb/config.json, else https://uniweb.app —
920
- // and never the project's backend or the current session *[Diego, 2026-09-21: "the
921
- // default backend for login, if not specified, is uniweb.app"]*. The backend logged in
922
- // to becomes CURRENT, and every backend command goes there (only UNIWEB_REGISTER_URL
923
- // outranks it) — which is why this is the one place a backend is chosen.
920
+ // and never the project's backend *[Diego, 2026-09-21: "the default backend for login, if
921
+ // not specified, is uniweb.app"]*. ⭐ Except a WORKSPACE SWITCH — `--org` / `--personal`
922
+ // and no way of signing in — which acts on the backend you are logged in to *[Diego,
923
+ // 2026-10-06]* (`resolveLoginOrigin`). The backend logged in to becomes CURRENT, and
924
+ // every backend command goes there (only UNIWEB_REGISTER_URL outranks it) — which is why
925
+ // this is the one place a backend is chosen.
924
926
  //
925
927
  // ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
926
928
  // asked when the machine knew several backends. Both are gone: the default is the
@@ -932,7 +934,7 @@ async function main() {
932
934
  const { resolveLoginOrigin } = await import('./utils/config.js')
933
935
  let apiBase
934
936
  try {
935
- apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'))
937
+ apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'), loginArgs)
936
938
  } catch (err) {
937
939
  console.error(
938
940
  `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --backend http://localhost:8080`
@@ -1729,8 +1731,8 @@ Auto-detects what you run it in:
1729
1731
 
1730
1732
  A foundation's scope is part of its name — name: '@scope/<name>' in main.js. A scope
1731
1733
  is a namespace: your personal one (@<your handle>, no org needed) or an org's. A bare
1732
- name takes --scope, else your personal scope (or a pick, if you belong to orgs), and
1733
- register writes it into the name.
1734
+ name takes --scope, else the workspace you work in (an org's scope, or your personal
1735
+ one), and register writes it into the name.
1734
1736
 
1735
1737
  Schema scopes:
1736
1738
  @/name your own schema, in the foundation's scope (@/x -> @org/x)
@@ -1742,6 +1744,9 @@ ${colors.bright}Options:${colors.reset}
1742
1744
  org's — and write it into the name (refused when the name has another
1743
1745
  scope). A schemas-only package: publish under @scope; default:
1744
1746
  package.json uniweb.scope
1747
+ --org @org Work in @org for this command: a bare name registers under it
1748
+ --personal Work in your personal workspace: a bare name registers under your
1749
+ personal scope
1745
1750
  --dry-run Print the .uwx; submit nothing
1746
1751
  -o, --output <f> Write the .uwx to a file; submit nothing
1747
1752
  --non-interactive Fail with usage info instead of prompting
@@ -1798,11 +1803,12 @@ ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE w
1798
1803
  personal one, or an organization's — and every push, pull and publish works in it:
1799
1804
  a site it creates is created there, and a site kept in another workspace is refused.
1800
1805
  With no organization it is your personal workspace; with organizations you are asked,
1801
- or name it. Already logged in, \`uniweb login --org @other\` switches without logging
1802
- in again.
1806
+ or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
1807
+ the workspace on the backend you are logged in to, without logging in again.
1803
1808
 
1804
1809
  ${colors.bright}Options:${colors.reset}
1805
- --backend <url> The backend to log in to
1810
+ --backend <url> The backend to log in to (without it: the default backend — or, for
1811
+ --org / --personal alone, the one you are logged in to)
1806
1812
  --org @org Work in @org (an organization you belong to)
1807
1813
  --personal Work in your personal workspace
1808
1814
  --token <bearer> Seed + verify a session from a bearer token (non-interactive)
@@ -1813,6 +1819,8 @@ ${colors.bright}Options:${colors.reset}
1813
1819
  In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1814
1820
  \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
1815
1821
  \`UNIWEB_TOKEN\` (per process, not stored) with \`UNIWEB_WORKSPACE=@org\` or \`personal\`.
1822
+ A login with organizations that names no workspace signs you in but exits 2;
1823
+ \`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
1816
1824
  \`uniweb logout\` logs you out.
1817
1825
  `,
1818
1826
  refresh: `
@@ -2089,6 +2097,16 @@ ${colors.bright}Global Options:${colors.reset}
2089
2097
  a script can aim and authenticate one process with UNIWEB_REGISTER_URL and
2090
2098
  UNIWEB_TOKEN instead.
2091
2099
 
2100
+ ${colors.bright}Push Options:${colors.reset}
2101
+ --dry-run Report what would be pushed; send nothing (-o <file> writes the .uwx)
2102
+ --no-release Send content against the already-released code; release nothing
2103
+ --bump Release above a newer registered foundation version
2104
+ --force Overwrite upstream changes (drop the staleness gate)
2105
+ --all Send every record (bypass the changed-only cache)
2106
+ --org @org Work in @org for this push, not your login's workspace
2107
+ --personal Work in your personal workspace for this push
2108
+ --no-validate Skip the content-conformance check (it stops a push)
2109
+
2092
2110
  ${colors.bright}Publish Options:${colors.reset}
2093
2111
  --dry-run Resolve everything; release/sync/POST nothing
2094
2112
  --yes Skip confirmations (CI); never block on a prompt
@@ -107,32 +107,87 @@ export function getDefaultBackendOrigin() {
107
107
  }
108
108
 
109
109
  /**
110
- * The backend `uniweb login` logs in to: `--backend`, else the default backend.
110
+ * The `uniweb login` that SWITCHES the workspace on `origin` — the caller appends `--org @x`
111
+ * or `--personal` — with `--backend` unless a switch without it reaches `origin`
112
+ * (`resolveLoginOrigin`): the backend you are logged in to, or, logged in nowhere, the
113
+ * default backend.
114
+ *
115
+ * ⚠️ *Measured 2026-10-06 against a local backend, before switches acted on the session:*
116
+ * `uniweb login --org @acme` went to https://uniweb.app and began a new login there — the
117
+ * reason a hint about any other backend names it.
118
+ *
119
+ * @param {string} origin - the backend the hint is about
120
+ * @param {string} [prefix='uniweb'] - how the user runs the CLI (`getCliPrefix`)
121
+ * @returns {string} e.g. `uniweb login`, or `uniweb login --backend http://localhost:8080`
122
+ */
123
+ export function loginCommand(origin, prefix = 'uniweb') {
124
+ const o = originOrNull(origin)
125
+ const reached = originOrNull(loggedInOrigin()) || getDefaultBackendOrigin()
126
+ return o && o !== reached ? `${prefix} login --backend ${o}` : `${prefix} login`
127
+ }
128
+
129
+ /** The flags with which `uniweb login` SIGNS IN — a method, or a credential. */
130
+ const SIGN_IN_FLAGS = ['--token', '--browser', '--password', '--token-paste']
131
+
132
+ /**
133
+ * Whether a `uniweb login` is a WORKSPACE SWITCH: `--org` or `--personal`, and nothing that
134
+ * signs in (`SIGN_IN_FLAGS`).
135
+ *
136
+ * @param {string[]} [args]
137
+ * @returns {boolean}
138
+ */
139
+ export function isWorkspaceSwitch(args = []) {
140
+ const names = args.map((a) => String(a).split('=')[0])
141
+ return (
142
+ (names.includes('--org') || names.includes('--personal')) &&
143
+ !SIGN_IN_FLAGS.some((f) => names.includes(f))
144
+ )
145
+ }
146
+
147
+ /**
148
+ * The backend `uniweb login` logs in to: `--backend`; else, for a workspace switch, the
149
+ * backend you are logged in to; else the default backend.
150
+ *
151
+ * ⭐ A SWITCH ACTS ON YOUR SESSION *[Diego, 2026-10-06: "`uniweb login --org @x` or
152
+ * `--personal`, with no `--backend`, should switch to the workspace on the backend you're
153
+ * already logged in to so we can fullfil the promise 'switches without logging in again'"]*.
154
+ * Logged in nowhere, it is a first login, and goes where any first login goes. A login that
155
+ * signs in — a method or a credential named (`isWorkspaceSwitch`) — still goes to the
156
+ * default backend *[Diego, 2026-09-21: "the default backend for login, if not specified, is
157
+ * uniweb.app"]*. ⛔ Until 2026-10-06 a switch went to the default backend too, so on any
158
+ * other backend it began a new login there, logging you out of the one you were on.
111
159
  *
112
160
  * ⛔ A mistyped `--backend` is an error, never a fallback — it would log you in, and so
113
161
  * point every command, somewhere you did not name.
114
162
  *
115
163
  * @param {string|null|undefined} flag - `readFlagValue`'s answer: undefined when the
116
164
  * flag is absent, null when it was given with no value
165
+ * @param {string[]} [args] - the login's argv, to tell a switch from a sign-in
117
166
  * @returns {string}
118
167
  * @throws {Error} when --backend was given and is not a URL
119
168
  */
120
- export function resolveLoginOrigin(flag) {
121
- if (flag === undefined) return getDefaultBackendOrigin()
169
+ export function resolveLoginOrigin(flag, args = []) {
170
+ if (flag === undefined) {
171
+ const session = isWorkspaceSwitch(args) ? originOrNull(loggedInOrigin()) : null
172
+ return session || getDefaultBackendOrigin()
173
+ }
122
174
  const origin = originOrNull(flag)
123
175
  if (!origin) throw new Error(flag ? `Not a URL: ${flag}` : '--backend needs a URL')
124
176
  return origin
125
177
  }
126
178
 
127
179
  /**
128
- * **The backend a command talks to** when no `--backend` is given — the base of every
129
- * `/dev/*` route (`register` POSTs to {origin}/dev/registry/register, and so on).
180
+ * **The backend a command talks to** — the base of every `/dev/*` route (`register`
181
+ * POSTs to {origin}/dev/registry/register, and so on).
130
182
  *
131
183
  * `UNIWEB_REGISTER_URL` > **the backend the user is logged in to** > the default
132
184
  * (`getDefaultBackendOrigin`). ⭐ Nothing talks to a backend the user is not logged in
133
185
  * to *[Diego, 2026-09-21]*: when this falls through to the default, the command's first
134
186
  * request asks for that login — so the default is where the login goes, never a
135
- * backend reached without one. `resolveBackendOrigin` puts `--backend` on top.
187
+ * backend reached without one. `resolveBackendOrigin` (backend/client.js) returns this
188
+ * unchanged: no command takes a `--backend`. ⛔ *Until 2026-10-05 this said "when no
189
+ * `--backend` is given" and that `resolveBackendOrigin` put `--backend` on top — read as
190
+ * a per-command flag outranking the login. The verbs lost that flag on 2026-09-21.*
136
191
  *
137
192
  * @returns {string}
138
193
  */
@@ -98,7 +98,8 @@ export function getRegistryAuthPath() {
98
98
  * always logs you out of the one backend you may be logged in"]*. A login REPLACES the
99
99
  * file — once it succeeds, so a cancelled or failed login leaves you where you were —
100
100
  * and a logout deletes it. So "the backend you are logged in to" is exactly one thing
101
- * or nothing, and logout needs no selector.
101
+ * or nothing, and logout needs no selector. ⚠️ One exception, and it says so: a login that
102
+ * signs in but cannot choose a workspace keeps the session and exits 2 (`finishLogin`).
102
103
  *
103
104
  * ⚖️ The v2 map is kept, holding one entry: the file then has one reader
104
105
  * (utils/session-file.js), and it still understands what came before — a v1 flat
@@ -720,7 +721,12 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
720
721
  if (wants || !session.workspace) {
721
722
  const settled = await settleWorkspace(session, args)
722
723
  if (settled.refused) {
723
- console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`)
724
+ // Say what the session works in now: unchanged, or — one from before workspaces —
725
+ // nothing yet (`finishLogin`).
726
+ const where = session.workspace
727
+ ? `${await workspaceTail(session)} — unchanged`
728
+ : ' — with no workspace chosen yet'
729
+ console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${where}.`)
724
730
  console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
725
731
  process.exit(2)
726
732
  }
@@ -839,15 +845,22 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
839
845
 
840
846
  /**
841
847
  * A login succeeded and its session is stored: choose its workspace, and say where the
842
- * login works. Without a workspace (no terminal, organizations, no flag) the session
843
- * stays, the reason is printed, and the login exits 2 — every command would refuse.
848
+ * login works.
849
+ *
850
+ * ⭐ WITHOUT A WORKSPACE THE SESSION STAYS, AND THE LOGIN SAYS SO *[Diego, 2026-10-06]*. No
851
+ * terminal, organizations, neither `--org` nor `--personal` — or a pick cancelled at a
852
+ * terminal: you are logged in, with no workspace, and the login exits 2 naming the one
853
+ * command that finishes it without signing in again. The credential was good, and a
854
+ * second sign-in can be a second browser round trip. ⚠️ So this is the one login that
855
+ * exits non-zero having replaced your session — the reason it says so in as many words.
844
856
  */
845
857
  async function finishLogin(record, apiBase, args) {
846
858
  const settled = await settleWorkspace(record, args)
847
859
  const who = settled.record.username ? ` as \x1b[1m${settled.record.username}\x1b[0m` : ''
848
860
  if (settled.refused) {
849
- console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase}).`)
861
+ console.error(`\x1b[32m✓\x1b[0m Logged in to ${normOrigin(apiBase)}${who} — with no workspace chosen yet.`)
850
862
  console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
863
+ console.error('\x1b[2m Until one is chosen, the commands that work on a site refuse.\x1b[0m')
851
864
  process.exit(2)
852
865
  }
853
866
  console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase})${await workspaceTail(settled.record)}.`)
@@ -81,6 +81,25 @@ export function publishScope(value) {
81
81
  return handle ? `@${handle}` : null
82
82
  }
83
83
 
84
+ /**
85
+ * Whether the orgs read (`GET /dev/orgs`) says this account may release into `scope`:
86
+ * the account's own handle, or an org it belongs to. True or false; null when there is
87
+ * nothing to say (no scope, no read).
88
+ *
89
+ * ⛔ For EXPLAINING a refusal only, never to gate a release: the registry decides who
90
+ * may publish into a scope, and this knows only the memberships that read lists.
91
+ *
92
+ * @param {string|null} scope - `@acme`, `acme` or `@acme/…`
93
+ * @param {{ account_handle?: string|null, orgs?: Array<{ handle?: string }> }|null} envelope
94
+ * @returns {boolean|null}
95
+ */
96
+ export function belongsToScope(scope, envelope) {
97
+ const h = bareHandle(scope)
98
+ if (!h || !envelope || typeof envelope !== 'object') return null
99
+ if (bareHandle(envelope.account_handle || '') === h) return true
100
+ return (Array.isArray(envelope.orgs) ? envelope.orgs : []).some((o) => bareHandle(o?.handle) === h)
101
+ }
102
+
84
103
  /**
85
104
  * Validate a handle's GRAMMAR client-side (reserved names are the server's
86
105
  * call — a 409 carries the verdict). Returns an error string, or null.
@@ -157,15 +176,22 @@ export async function createOrg({ apiBase, token, handle }) {
157
176
  }
158
177
 
159
178
  /**
160
- * The scope to register under, from the login, when none was named — a bare handle,
161
- * or null when none was chosen. Persists nothing; the caller records it (in the
162
- * foundation's name, or a schemas-only package's `package.json`).
179
+ * The scope to register under, from the login's orgs, when none was named and the
180
+ * workspace the command works in names none either — a bare handle, or null when none
181
+ * was chosen. Persists nothing; the caller records it (in the foundation's name, or a
182
+ * schemas-only package's `package.json`).
183
+ *
184
+ * ⚠️ The FALLBACK since 2026-10-06: a bare name first takes the workspace the command
185
+ * works in (`register.js::deriveScopeFromLogin`, `workspace.js::scopeOfWorkspace`). This
186
+ * answers only when no workspace is chosen — you belong to orgs and named none — or the
187
+ * one chosen is a unit without a handle.
163
188
  *
164
189
  * ⭐ A SCOPE IS A NAMESPACE (2026-09-23): your account's own, `@<handle>`, needs no org.
165
190
  *
166
191
  * no org → your personal scope — said, never asked, in CI too
167
192
  * orgs → pick: your personal scope first, then each org;
168
- * non-interactive, your personal scope, said
193
+ * non-interactive, REFUSED — choose a workspace, or --scope
194
+ * (until 2026-10-06: your personal scope, said)
169
195
  * no account handle → (a service account) its one org, said; several are asked,
170
196
  * or refused in CI; none is a pointer to `--scope`
171
197
  *
@@ -220,10 +246,20 @@ export async function deriveScope({
220
246
  )
221
247
  return null
222
248
  }
249
+ // ⛔ NO DEFAULT FOR A MEMBER OF ORGANIZATIONS *[Diego, 2026-10-06: "someone in an org,
250
+ // with no workspace chosen must choose one. we should not default to personal"]*. Only
251
+ // reached when the workspace the command works in names no scope — none is chosen — so
252
+ // the choice is theirs. ⚠️ Until then this answered your personal scope, said: a team's
253
+ // foundation registered as one member's.
254
+ const { getCliPrefix } = await import('./interactive.js')
255
+ const { loginCommand } = await import('./config.js')
256
+ const first = orgs[0].handle
223
257
  console.error(
224
- `Registering under your personal scope ${bold(personal)} (non-interactive). Pass --scope @org for an org.`
258
+ `\x1b[31m✗\x1b[0m You belong to organizations (${orgs.map((o) => `@${o.handle}`).join(', ')}), so no scope is assumed.\n` +
259
+ ` Choose the workspace you work in — ${loginCommand(apiBase, getCliPrefix())} --org @${first} (or --personal) — and the name takes its scope;\n` +
260
+ ` or name the scope here: --scope @${first}, or --scope @${personal} for your own.`
225
261
  )
226
- return personal
262
+ return null
227
263
  }
228
264
  const prompts = (await import('prompts')).default
229
265
  const { choice } = await prompts(
@@ -249,5 +285,6 @@ export async function deriveScope({
249
285
  }
250
286
  }
251
287
  )
288
+ if (!choice) console.error('\x1b[31m✗\x1b[0m No scope chosen.')
252
289
  return choice || null
253
290
  }