uniweb 0.82.2 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +35 -14
- package/src/backend/client.js +84 -13
- package/src/backend/foundation-bring-along.js +81 -11
- package/src/backend/service-request.js +204 -173
- package/src/backend/site-sync.js +72 -3
- package/src/backend/workspace.js +52 -17
- package/src/commands/publish.js +39 -159
- package/src/commands/push.js +28 -4
- package/src/commands/register.js +64 -19
- package/src/commands/status.js +32 -3
- package/src/framework-index.json +6 -6
- package/src/index.js +28 -10
- package/src/utils/config.js +61 -6
- package/src/utils/registry-auth.js +18 -5
- package/src/utils/registry-orgs.js +43 -6
package/src/commands/publish.js
CHANGED
|
@@ -55,10 +55,9 @@ import {
|
|
|
55
55
|
} from '@uniweb/build/site'
|
|
56
56
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
57
57
|
import {
|
|
58
|
-
|
|
59
|
-
fingerprintRequest,
|
|
58
|
+
bankLanguages,
|
|
60
59
|
reconcile,
|
|
61
|
-
|
|
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
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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
|
-
|
|
496
|
-
say.
|
|
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
|
-
|
|
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
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
828
|
+
// ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
|
|
910
829
|
//
|
|
911
|
-
// `publishLanguages` is a request like
|
|
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
|
-
|
|
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
|
|
1130
|
-
//
|
|
1131
|
-
|
|
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
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
278
|
-
|
|
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(', ')}` : '')
|
package/src/commands/register.js
CHANGED
|
@@ -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 } |
|
|
320
|
-
* null when refused
|
|
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
|
|
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
|
-
*
|
|
379
|
-
*
|
|
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
|
|
589
|
-
//
|
|
590
|
-
// A SCHEMAS-ONLY package has no name to carry one:
|
|
591
|
-
// `uniweb.scope`, else (real submit only)
|
|
592
|
-
// spelling, `@acme` or `acme`, and from here on the one
|
|
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 →
|
|
695
|
-
//
|
|
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:
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
)
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
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)
|
|
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/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
130
|
+
"version": "0.10.21",
|
|
131
131
|
"path": "framework/unipress",
|
|
132
132
|
"deps": [
|
|
133
133
|
"@uniweb/build",
|