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.
@@ -55,10 +55,9 @@ import {
55
55
  } from '@uniweb/build/site'
56
56
  import { emitSyncPackages } from '@uniweb/build/uwx'
57
57
  import {
58
- decideDeclaration,
59
- fingerprintRequest,
58
+ bankLanguages,
60
59
  reconcile,
61
- reconcileRequest
60
+ settleServices
62
61
  } from '../backend/service-request.js'
63
62
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
64
63
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
@@ -235,19 +234,6 @@ export function unservedLanguages(asked, served) {
235
234
  return asked.filter((l) => !got.has(l))
236
235
  }
237
236
 
238
- function describeServices(rows) {
239
- if (!Array.isArray(rows) || rows.length === 0) return 'nothing'
240
- return rows
241
- .map((r) => {
242
- const name = typeof r?.name === 'string' ? r.name : '?'
243
- // A row that omits `enabled` is an ask, not a refusal — the backend's three
244
- // states. Only an explicit `false` reads as off.
245
- return r?.enabled === false ? `${name} (off)` : name
246
- })
247
- .sort()
248
- .join(', ')
249
- }
250
-
251
237
  async function persistLastDeploy(siteDir, opts) {
252
238
  if (opts.saveDeploys === false) return
253
239
  try {
@@ -392,9 +378,9 @@ export async function publish(args = []) {
392
378
  resolved = resolvePublishTarget(deployYml, client.origin, {
393
379
  defaultBackend: DEFAULT_BACKEND_ORIGIN
394
380
  })
395
- // The last request we are known to have sent TO THIS BACKEND, for the declaration
396
- // gate below. Read from the SAME deploy.yml load — one read, and the memo is the
397
- // only durable record of it (see backend/service-request.js for why not the cache).
381
+ // The language selection last sent TO THIS BACKEND, for its reconcile below. Read
382
+ // from the SAME deploy.yml load — one read, and the memo is the only durable record
383
+ // of it (see backend/service-request.js).
398
384
  priorRequest = deployYml?.deploys?.[resolved.targetName] || null
399
385
  } catch {
400
386
  // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
@@ -492,8 +478,9 @@ export async function publish(args = []) {
492
478
  cliBin: process.argv[1]
493
479
  })
494
480
  } catch (err) {
495
- say.err(`Foundation release failed: ${err.message}`)
496
- say.dim('Fix the foundation, then re-run `uniweb publish`.')
481
+ // A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
482
+ say.err(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
483
+ for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb publish`.']) say.dim(line)
497
484
  return { exitCode: 1 }
498
485
  }
499
486
 
@@ -512,8 +499,8 @@ export async function publish(args = []) {
512
499
  cliBin: process.argv[1]
513
500
  })
514
501
  } catch (err) {
515
- say.err(`Extension release failed: ${err.message}`)
516
- say.dim('Fix the extension, then re-run `uniweb publish`.')
502
+ say.err(err.notMember ? err.message : `Extension release failed: ${err.message}`)
503
+ for (const line of err.notMember ? err.ways : ['Fix the extension, then re-run `uniweb publish`.']) say.dim(line)
517
504
  return { exitCode: 1 }
518
505
  }
519
506
  if (!ext.proceed) return { exitCode: 1 }
@@ -817,98 +804,30 @@ export async function publish(args = []) {
817
804
  const injectInfo = {
818
805
  ...(fnd.ref ? { foundation: fnd.ref } : {})
819
806
  }
820
- // ⛔ IS THE FILE ASKING FOR ANYTHING BY ITS `$services` / `$secrets` BLOCK?
821
- //
822
- // The blocks ride inside the site-content document, so without this gate every
823
- // push re-sends them — and the backend REPLACES what it is sent. A paragraph
824
- // edit would therefore overwrite whatever the stored request has become, which
825
- // in the consent workflow is a decision the owner made in the app. Under "the
826
- // file is a request", an unchanged block is not asking for anything.
827
- //
828
- // ⚠️ The residual window, stated because it is real and narrow: the base is
829
- // banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
830
- // the next publish is not seen — the pulled block reads as unchanged-from-nothing
831
- // and is declared. It closes when the status route carries the stored request
832
- // (backend is adding it) and we compare against theirs instead of our memory.
833
- //
834
- // ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
835
- // banked fingerprint says what WE last sent; the status read says what the site
836
- // actually has. Only the second one sees a change made in the app, which is where
837
- // the consent workflow's decisions happen — so this is what closes the window
838
- // between a `uniweb pull` and the next publish.
839
- //
840
- // ⚖️ Degrades to the banked comparison on any failure — an older backend, a
841
- // network blip, a site never pushed. That is the shipped behaviour and it is safe:
842
- // it withholds an unchanged block and sends a changed one; it merely cannot see
843
- // the app's side.
844
- // What this site is PROVISIONED with on the backend being published to.
845
- const provisioned = readBackendState(siteDir, client.origin)
846
- const boundUuid = provisioned.site?.uuid || null
847
- let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
848
- let adopted = null
849
- // Before the push, so a never-synced site has no uuid and simply skips this.
850
- const status =
851
- // This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
852
- // the remote reconcile below never ran and every publish declared blind.
853
- boundUuid
854
- ? await client.siteStatus(boundUuid)
855
- : null
856
- if (status && Array.isArray(status.services)) {
857
- const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
858
- if (r.action === 'none') {
859
- declaration = { declare: false, reason: 'in-sync' }
860
- } else if (r.action === 'send') {
861
- declaration = { declare: true, reason: 'changed' }
862
- } else if (r.action === 'adopt') {
863
- // The owner decided in the app and this file is simply behind. Nothing to
864
- // ask for, so nothing is sent — and the file can be brought in line, which
865
- // is offered rather than done, because site.yml is theirs.
866
- declaration = { declare: false, reason: 'adopt' }
867
- adopted = status.services
868
- } else {
869
- // ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
870
- // publish is a separate thing the owner asked for, and blocking it over a
871
- // services disagreement couples two unrelated intents. Not a guess either;
872
- // the request stays in their file, unsent, and they are told.
873
- declaration = { declare: false, reason: 'conflict' }
874
- adopted = status.services
875
- }
876
- }
807
+ // ⭐ WHAT THE SITE HAS — read before the push, for the two requests a publish
808
+ // carries: the services (`site.yml::services`) and the language selection. Only
809
+ // this read sees a decision the owner made in the app since this clone last synced.
810
+ // It reads this backend's site, from sync.json; a never-synced site has none.
811
+ const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
812
+ const status = boundUuid ? await client.siteStatus(boundUuid) : null
877
813
 
878
- // ⛔ EVERY STRING BELOW IS FOR A SITE OWNER, NOT FOR US.
879
- //
880
- // "request", "declaration", "send", "adopt", "reconcile" are how this file
881
- // MODELS the problem and they are the wrong words to say out loud: an author
882
- // does not think they are sending a request, they think they want their site to
883
- // have search. Say services, on and off, site.yml and your site. The internal
884
- // vocabulary stays in the code and the comments, where it earns its precision.
885
- //
886
- // ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
887
- //
888
- // `conflict` means the file and the site both moved since we last agreed, so
889
- // neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
890
- // is advice that CANNOT WORK: with no banked base the file has nothing to move
891
- // relative to, so editing it produces the same conflict forever. That shipped
892
- // for one commit. Asking is the only thing that resolves it.
893
- if (declaration.reason === 'conflict') {
894
- say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
895
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
896
- say.dim(` on your site: ${describeServices(adopted)}`)
897
- if (isNonInteractive(args)) {
898
- say.dim(' Left your site as it is — run without --non-interactive to choose.')
899
- } else if (await confirm('Use the services listed in site.yml?', false)) {
900
- declaration = { declare: true, reason: 'resolved-send' }
901
- adopted = null
902
- } else {
903
- // Declining to send is not yet a decision to take theirs, so this falls
904
- // through to the offer below and "neither, leave it alone" stays available.
905
- declaration = { declare: false, reason: 'adopt' }
906
- }
907
- }
814
+ // ⭐ THE SERVICES: what the owner changed in `site.yml::services` is sent, applied
815
+ // over the site's own list; what the site changed is kept and offered into the file;
816
+ // where both changed, the owner is asked (`settleServices`). An ask whose decision is
817
+ // still open is never sent over the decision the site holds.
818
+ const services = await settleServices({
819
+ client,
820
+ siteDir,
821
+ siteYml,
822
+ status,
823
+ interactive: !isNonInteractive(args),
824
+ confirm,
825
+ say
826
+ })
908
827
 
909
- // ⭐ THE SAME QUESTION FOR THE LANGUAGE SELECTION, and it is the one that costs.
828
+ // ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
910
829
  //
911
- // `publishLanguages` is a request like `$services`: pushed up, projected back on
830
+ // `publishLanguages` is a request like `services`: pushed up, projected back on
912
831
  // pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
913
832
  // today — which is why this was nearly skipped — but a base is not only for
914
833
  // detecting an overwrite. Without one, a selection that has always been in the
@@ -937,34 +856,6 @@ export async function publish(args = []) {
937
856
  }
938
857
  }
939
858
 
940
- if (declaration.reason === 'adopt' && adopted) {
941
- // ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
942
- // decision made in the app — but the same state follows a request of ours the
943
- // site refused, where nothing of theirs changed and ours simply did not take.
944
- // We cannot tell those apart here, so the wording claims neither.
945
- say.info('Your site has different services than site.yml lists.')
946
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
947
- say.dim(` on your site: ${describeServices(adopted)}`)
948
- // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
949
- // silently rewrites an authored file is the surprise this seam exists to
950
- // avoid. Default No, and declining costs nothing: the site is already
951
- // correct, only the file is behind, and the offer returns next publish.
952
- //
953
- // ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
954
- // been asked which they meant and said "not mine", taking the site's is the
955
- // other half of the same question, not a silent overwrite of an edit.
956
- if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
957
- const { writeSiteConfig } = await import('@uniweb/build/uwx')
958
- writeSiteConfig(siteDir, { $services: adopted })
959
- // Keep the in-memory copy in step, or the deploy.yml bank below records the
960
- // file as it WAS and the offer repeats forever.
961
- siteYml.$services = adopted
962
- say.ok('site.yml updated.')
963
- }
964
- } else if (!declaration.declare && declaration.reason !== 'adopt') {
965
- say.dim('Services unchanged.')
966
- }
967
-
968
859
  // publish rides the same gated push as `uniweb push`: if an app author has
969
860
  // edited since this clone last synced, the push is refused rather than
970
861
  // overwriting them, and nothing goes live. `--force` drops the precondition.
@@ -982,7 +873,8 @@ export async function publish(args = []) {
982
873
  backend: client.origin,
983
874
  // The keys this deployment's Sections take — see deploymentFields.
984
875
  ...fields,
985
- ...(declaration.declare ? {} : { declareServices: false }),
876
+ // The `services` Section as settled above — or withheld.
877
+ ...services.emit,
986
878
  // Placement identity for the folder — see writeFolderItemUuids.
987
879
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
988
880
  // Identity for the records' list items — see readRecordItemUuids.
@@ -1029,6 +921,9 @@ export async function publish(args = []) {
1029
921
  report
1030
922
  })
1031
923
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
924
+ // The push stored what it sent: the services agreed on are recorded now, whether or
925
+ // not the site then goes live.
926
+ services.after()
1032
927
  const siteUuid = pushResult.boundSiteUuid
1033
928
  if (!siteUuid) {
1034
929
  say.err('Push did not yield a site uuid — cannot go live.')
@@ -1126,24 +1021,9 @@ export async function publish(args = []) {
1126
1021
  lastDeploy: {
1127
1022
  at: new Date().toISOString(),
1128
1023
  host: 'uniweb',
1129
- // The request this publish is known to have sent — the base the declaration
1130
- // gate compares against next time. ⛔ A FINGERPRINT, never the block:
1131
- // deploy.yml is committed, `$secrets` carries secret material and a
1132
- // service's `config` is opaque, so recording either verbatim would write
1133
- // them into git. Absent when the file declares no block.
1134
- ...(declaration.declare
1135
- ? fingerprintRequest(siteYml, provisioned)
1136
- : {
1137
- // Nothing was sent, so the base is unchanged — carry it forward
1138
- // rather than dropping it, or the next publish would read "no record"
1139
- // and declare.
1140
- ...(priorRequest?.servicesRequest
1141
- ? { servicesRequest: priorRequest.servicesRequest }
1142
- : {}),
1143
- ...(priorRequest?.secretsRequest
1144
- ? { secretsRequest: priorRequest.secretsRequest }
1145
- : {})
1146
- }),
1024
+ // The language selection this publish sent — the base its reconcile compares
1025
+ // against next time. ⛔ A FINGERPRINT: the selection itself is in site.yml.
1026
+ ...bankLanguages(siteYml),
1147
1027
  // What was actually shipped. A version number can't answer that — two
1148
1028
  // publishes of "0.1.0" are not the same content — and after the fact the
1149
1029
  // working tree has moved on. `dirty` matters as much as the sha: it says the
@@ -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
@@ -71,7 +74,8 @@ import {
71
74
  syncedElsewhere,
72
75
  describeSyncedElsewhere
73
76
  } from '../utils/site-identity.js'
74
- import { confirm } from '../utils/interactive.js'
77
+ import { confirm, isNonInteractive } from '../utils/interactive.js'
78
+ import { settleServices } from '../backend/service-request.js'
75
79
  import { guardEmptyRecords } from '../utils/records-guard.js'
76
80
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
77
81
  import {
@@ -274,8 +278,9 @@ export async function push(args = [], deps = {}) {
274
278
  verb: 'push'
275
279
  })
276
280
  } catch (err) {
277
- error(`Foundation release failed: ${err.message}`)
278
- note('Fix the foundation, then re-run `uniweb push`.')
281
+ // A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
282
+ error(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
283
+ for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb push`.']) note(line)
279
284
  return { exitCode: 1 }
280
285
  }
281
286
  if (!fnd.proceed) return { exitCode: 1 }
@@ -516,6 +521,20 @@ export async function push(args = [], deps = {}) {
516
521
  ref: siteYml?.foundation
517
522
  })
518
523
  }
524
+ // ⭐ THE SERVICES `site.yml` ASKS FOR — what the owner changed is sent over the site's
525
+ // own list; what the site changed is kept and offered into the file; where both
526
+ // changed, the owner is asked. Shared with `uniweb publish` (`settleServices`).
527
+ // Offline for `-o` and `--dry-run`: nothing is read, and nothing is recorded.
528
+ const offline = Boolean(output) || dryRun
529
+ const services = await settleServices({
530
+ client,
531
+ siteDir,
532
+ siteYml,
533
+ offline,
534
+ interactive: !offline && !isNonInteractive(args),
535
+ confirm,
536
+ say: { info, warn, dim: note, ok: success }
537
+ })
519
538
  const emitOptions = {
520
539
  backend: client.origin,
521
540
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -551,7 +570,9 @@ export async function push(args = [], deps = {}) {
551
570
  itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
552
571
  }),
553
572
  ...(assetRewrite ? { assetRewrite } : {}),
554
- ...(assetIds ? { assetIds } : {})
573
+ ...(assetIds ? { assetIds } : {}),
574
+ // The `services` Section as settled above — or withheld.
575
+ ...services.emit
555
576
  }
556
577
  let pkg
557
578
  try {
@@ -580,6 +601,8 @@ export async function push(args = [], deps = {}) {
580
601
 
581
602
  // Nothing changed since the last push — the backend is already up to date.
582
603
  if (totalEntities === 0) {
604
+ // The site already holds what this copy has, so what was settled is agreed.
605
+ if (!offline) services.after()
583
606
  success(
584
607
  `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
585
608
  )
@@ -640,6 +663,7 @@ export async function push(args = [], deps = {}) {
640
663
  }
641
664
  })
642
665
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
666
+ services.after()
643
667
  success(
644
668
  `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
645
669
  (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
@@ -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(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-06T02:16:00.292Z",
3
+ "generatedAt": "2026-10-06T19:21:14.306Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.10",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.76.1",
13
+ "version": "0.77.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.37.1",
38
+ "version": "0.37.2",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.17",
58
+ "version": "0.19.18",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -84,7 +84,7 @@
84
84
  ]
85
85
  },
86
86
  "@uniweb/runtime": {
87
- "version": "0.29.1",
87
+ "version": "0.29.2",
88
88
  "path": "framework/runtime",
89
89
  "deps": [
90
90
  "@uniweb/core",
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.20",
130
+ "version": "0.10.21",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",