uniweb 0.83.0 → 0.85.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 +5 -5
- package/partials/agents.md +99 -51
- package/src/backend/service-request.js +146 -173
- package/src/backend/site-sync.js +154 -47
- package/src/commands/doctor.js +32 -36
- package/src/commands/publish.js +25 -155
- package/src/commands/pull.js +13 -2
- package/src/commands/push.js +6 -1
- package/src/framework-index.json +7 -7
package/src/commands/publish.js
CHANGED
|
@@ -55,10 +55,11 @@ 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
|
+
announceServices,
|
|
61
|
+
foundationSupports,
|
|
62
|
+
recordsNotAsked
|
|
62
63
|
} from '../backend/service-request.js'
|
|
63
64
|
import { isSiteRelativeExtensionUrl } from '@uniweb/build'
|
|
64
65
|
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
@@ -235,19 +236,6 @@ export function unservedLanguages(asked, served) {
|
|
|
235
236
|
return asked.filter((l) => !got.has(l))
|
|
236
237
|
}
|
|
237
238
|
|
|
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
239
|
async function persistLastDeploy(siteDir, opts) {
|
|
252
240
|
if (opts.saveDeploys === false) return
|
|
253
241
|
try {
|
|
@@ -392,9 +380,9 @@ export async function publish(args = []) {
|
|
|
392
380
|
resolved = resolvePublishTarget(deployYml, client.origin, {
|
|
393
381
|
defaultBackend: DEFAULT_BACKEND_ORIGIN
|
|
394
382
|
})
|
|
395
|
-
// The
|
|
396
|
-
//
|
|
397
|
-
//
|
|
383
|
+
// The language selection last sent TO THIS BACKEND, for its reconcile below. Read
|
|
384
|
+
// from the SAME deploy.yml load — one read, and the memo is the only durable record
|
|
385
|
+
// of it (see backend/service-request.js).
|
|
398
386
|
priorRequest = deployYml?.deploys?.[resolved.targetName] || null
|
|
399
387
|
} catch {
|
|
400
388
|
// Malformed/ambiguous deploy.yml — don't block the publish on the memo.
|
|
@@ -818,98 +806,20 @@ export async function publish(args = []) {
|
|
|
818
806
|
const injectInfo = {
|
|
819
807
|
...(fnd.ref ? { foundation: fnd.ref } : {})
|
|
820
808
|
}
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
// in the consent workflow is a decision the owner made in the app. Under "the
|
|
827
|
-
// file is a request", an unchanged block is not asking for anything.
|
|
828
|
-
//
|
|
829
|
-
// ⚠️ The residual window, stated because it is real and narrow: the base is
|
|
830
|
-
// banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
|
|
831
|
-
// the next publish is not seen — the pulled block reads as unchanged-from-nothing
|
|
832
|
-
// and is declared. It closes when the status route carries the stored request
|
|
833
|
-
// (backend is adding it) and we compare against theirs instead of our memory.
|
|
834
|
-
//
|
|
835
|
-
// ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
|
|
836
|
-
// banked fingerprint says what WE last sent; the status read says what the site
|
|
837
|
-
// actually has. Only the second one sees a change made in the app, which is where
|
|
838
|
-
// the consent workflow's decisions happen — so this is what closes the window
|
|
839
|
-
// between a `uniweb pull` and the next publish.
|
|
840
|
-
//
|
|
841
|
-
// ⚖️ Degrades to the banked comparison on any failure — an older backend, a
|
|
842
|
-
// network blip, a site never pushed. That is the shipped behaviour and it is safe:
|
|
843
|
-
// it withholds an unchanged block and sends a changed one; it merely cannot see
|
|
844
|
-
// the app's side.
|
|
845
|
-
// What this site is PROVISIONED with on the backend being published to.
|
|
846
|
-
const provisioned = readBackendState(siteDir, client.origin)
|
|
847
|
-
const boundUuid = provisioned.site?.uuid || null
|
|
848
|
-
let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
|
|
849
|
-
let adopted = null
|
|
850
|
-
// Before the push, so a never-synced site has no uuid and simply skips this.
|
|
851
|
-
const status =
|
|
852
|
-
// This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
|
|
853
|
-
// the remote reconcile below never ran and every publish declared blind.
|
|
854
|
-
boundUuid
|
|
855
|
-
? await client.siteStatus(boundUuid)
|
|
856
|
-
: null
|
|
857
|
-
if (status && Array.isArray(status.services)) {
|
|
858
|
-
const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
|
|
859
|
-
if (r.action === 'none') {
|
|
860
|
-
declaration = { declare: false, reason: 'in-sync' }
|
|
861
|
-
} else if (r.action === 'send') {
|
|
862
|
-
declaration = { declare: true, reason: 'changed' }
|
|
863
|
-
} else if (r.action === 'adopt') {
|
|
864
|
-
// The owner decided in the app and this file is simply behind. Nothing to
|
|
865
|
-
// ask for, so nothing is sent — and the file can be brought in line, which
|
|
866
|
-
// is offered rather than done, because site.yml is theirs.
|
|
867
|
-
declaration = { declare: false, reason: 'adopt' }
|
|
868
|
-
adopted = status.services
|
|
869
|
-
} else {
|
|
870
|
-
// ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
|
|
871
|
-
// publish is a separate thing the owner asked for, and blocking it over a
|
|
872
|
-
// services disagreement couples two unrelated intents. Not a guess either;
|
|
873
|
-
// the request stays in their file, unsent, and they are told.
|
|
874
|
-
declaration = { declare: false, reason: 'conflict' }
|
|
875
|
-
adopted = status.services
|
|
876
|
-
}
|
|
877
|
-
}
|
|
809
|
+
// ⭐ WHAT THE SITE HAS — read before the push, for the language selection. Only this
|
|
810
|
+
// read sees a decision the owner made in the app since this clone last synced. It
|
|
811
|
+
// reads this backend's site, from sync.json; a never-synced site has none.
|
|
812
|
+
const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
|
|
813
|
+
const status = boundUuid ? await client.siteStatus(boundUuid) : null
|
|
878
814
|
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
// does not think they are sending a request, they think they want their site to
|
|
884
|
-
// have search. Say services, on and off, site.yml and your site. The internal
|
|
885
|
-
// vocabulary stays in the code and the comments, where it earns its precision.
|
|
886
|
-
//
|
|
887
|
-
// ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
|
|
888
|
-
//
|
|
889
|
-
// `conflict` means the file and the site both moved since we last agreed, so
|
|
890
|
-
// neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
|
|
891
|
-
// is advice that CANNOT WORK: with no banked base the file has nothing to move
|
|
892
|
-
// relative to, so editing it produces the same conflict forever. That shipped
|
|
893
|
-
// for one commit. Asking is the only thing that resolves it.
|
|
894
|
-
if (declaration.reason === 'conflict') {
|
|
895
|
-
say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
|
|
896
|
-
say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
|
|
897
|
-
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
898
|
-
if (isNonInteractive(args)) {
|
|
899
|
-
say.dim(' Left your site as it is — run without --non-interactive to choose.')
|
|
900
|
-
} else if (await confirm('Use the services listed in site.yml?', false)) {
|
|
901
|
-
declaration = { declare: true, reason: 'resolved-send' }
|
|
902
|
-
adopted = null
|
|
903
|
-
} else {
|
|
904
|
-
// Declining to send is not yet a decision to take theirs, so this falls
|
|
905
|
-
// through to the offer below and "neither, leave it alone" stays available.
|
|
906
|
-
declaration = { declare: false, reason: 'adopt' }
|
|
907
|
-
}
|
|
908
|
-
}
|
|
815
|
+
// ⭐ THE SERVICES are stated by the push itself — the file's, and off for each held
|
|
816
|
+
// one it no longer lists (`statedServices`) — and the backend decides per service.
|
|
817
|
+
// Said here: what the file asks that will not be sent as written.
|
|
818
|
+
announceServices({ siteYml, say, supports: await foundationSupports(siteDir, siteYml) })
|
|
909
819
|
|
|
910
|
-
// ⭐ THE
|
|
820
|
+
// ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
|
|
911
821
|
//
|
|
912
|
-
// `publishLanguages` is a request like
|
|
822
|
+
// `publishLanguages` is a request like `services`: pushed up, projected back on
|
|
913
823
|
// pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
|
|
914
824
|
// today — which is why this was nearly skipped — but a base is not only for
|
|
915
825
|
// detecting an overwrite. Without one, a selection that has always been in the
|
|
@@ -938,34 +848,6 @@ export async function publish(args = []) {
|
|
|
938
848
|
}
|
|
939
849
|
}
|
|
940
850
|
|
|
941
|
-
if (declaration.reason === 'adopt' && adopted) {
|
|
942
|
-
// ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
|
|
943
|
-
// decision made in the app — but the same state follows a request of ours the
|
|
944
|
-
// site refused, where nothing of theirs changed and ours simply did not take.
|
|
945
|
-
// We cannot tell those apart here, so the wording claims neither.
|
|
946
|
-
say.info('Your site has different services than site.yml lists.')
|
|
947
|
-
say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
|
|
948
|
-
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
949
|
-
// ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
|
|
950
|
-
// silently rewrites an authored file is the surprise this seam exists to
|
|
951
|
-
// avoid. Default No, and declining costs nothing: the site is already
|
|
952
|
-
// correct, only the file is behind, and the offer returns next publish.
|
|
953
|
-
//
|
|
954
|
-
// ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
|
|
955
|
-
// been asked which they meant and said "not mine", taking the site's is the
|
|
956
|
-
// other half of the same question, not a silent overwrite of an edit.
|
|
957
|
-
if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
|
|
958
|
-
const { writeSiteConfig } = await import('@uniweb/build/uwx')
|
|
959
|
-
writeSiteConfig(siteDir, { $services: adopted })
|
|
960
|
-
// Keep the in-memory copy in step, or the deploy.yml bank below records the
|
|
961
|
-
// file as it WAS and the offer repeats forever.
|
|
962
|
-
siteYml.$services = adopted
|
|
963
|
-
say.ok('site.yml updated.')
|
|
964
|
-
}
|
|
965
|
-
} else if (!declaration.declare && declaration.reason !== 'adopt') {
|
|
966
|
-
say.dim('Services unchanged.')
|
|
967
|
-
}
|
|
968
|
-
|
|
969
851
|
// publish rides the same gated push as `uniweb push`: if an app author has
|
|
970
852
|
// edited since this clone last synced, the push is refused rather than
|
|
971
853
|
// overwriting them, and nothing goes live. `--force` drops the precondition.
|
|
@@ -983,7 +865,6 @@ export async function publish(args = []) {
|
|
|
983
865
|
backend: client.origin,
|
|
984
866
|
// The keys this deployment's Sections take — see deploymentFields.
|
|
985
867
|
...fields,
|
|
986
|
-
...(declaration.declare ? {} : { declareServices: false }),
|
|
987
868
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
988
869
|
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
989
870
|
// Identity for the records' list items — see readRecordItemUuids.
|
|
@@ -1017,6 +898,10 @@ export async function publish(args = []) {
|
|
|
1017
898
|
if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
|
|
1018
899
|
return { exitCode: 1 }
|
|
1019
900
|
}
|
|
901
|
+
// ⭐ `records` is what delivers the records the pages show on the published site — said
|
|
902
|
+
// here, at publish, and never at push: syncing records does not depend on it.
|
|
903
|
+
const notAsked = recordsNotAsked({ siteYml, shown: pkg.recordsShown })
|
|
904
|
+
if (notAsked) say.warn(notAsked)
|
|
1020
905
|
const report = {
|
|
1021
906
|
info: (m) => say.info(m),
|
|
1022
907
|
note: (m) => say.dim(m),
|
|
@@ -1127,24 +1012,9 @@ export async function publish(args = []) {
|
|
|
1127
1012
|
lastDeploy: {
|
|
1128
1013
|
at: new Date().toISOString(),
|
|
1129
1014
|
host: 'uniweb',
|
|
1130
|
-
// The
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
// service's `config` is opaque, so recording either verbatim would write
|
|
1134
|
-
// them into git. Absent when the file declares no block.
|
|
1135
|
-
...(declaration.declare
|
|
1136
|
-
? fingerprintRequest(siteYml, provisioned)
|
|
1137
|
-
: {
|
|
1138
|
-
// Nothing was sent, so the base is unchanged — carry it forward
|
|
1139
|
-
// rather than dropping it, or the next publish would read "no record"
|
|
1140
|
-
// and declare.
|
|
1141
|
-
...(priorRequest?.servicesRequest
|
|
1142
|
-
? { servicesRequest: priorRequest.servicesRequest }
|
|
1143
|
-
: {}),
|
|
1144
|
-
...(priorRequest?.secretsRequest
|
|
1145
|
-
? { secretsRequest: priorRequest.secretsRequest }
|
|
1146
|
-
: {})
|
|
1147
|
-
}),
|
|
1015
|
+
// The language selection this publish sent — the base its reconcile compares
|
|
1016
|
+
// against next time. ⛔ A FINGERPRINT: the selection itself is in site.yml.
|
|
1017
|
+
...bankLanguages(siteYml),
|
|
1148
1018
|
// What was actually shipped. A version number can't answer that — two
|
|
1149
1019
|
// publishes of "0.1.0" are not the same content — and after the fact the
|
|
1150
1020
|
// working tree has moved on. `dirty` matters as much as the sha: it says the
|
package/src/commands/pull.js
CHANGED
|
@@ -95,7 +95,8 @@ import {
|
|
|
95
95
|
rebankSyncHashes,
|
|
96
96
|
writeQueryUuids,
|
|
97
97
|
mergeBaseVersions,
|
|
98
|
-
|
|
98
|
+
writeItemBaseVersions,
|
|
99
|
+
pulledItemVersions,
|
|
99
100
|
writeUnitBases,
|
|
100
101
|
writeItemUuids,
|
|
101
102
|
writeFolderItemUuids
|
|
@@ -887,6 +888,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
887
888
|
// The Models the pulled site's queries name, as the push qualified them — the re-bank
|
|
888
889
|
// below resolves with them too (see there).
|
|
889
890
|
let pulledQueryModels = []
|
|
891
|
+
// The versions this copy holds after the pull: every site-content item the pulled
|
|
892
|
+
// document carries (`pulledItemVersions`), banked below once the files have taken it.
|
|
893
|
+
let pulledVersions = null
|
|
890
894
|
if (content && !content.notModified) {
|
|
891
895
|
const siteDoc =
|
|
892
896
|
content.docs &&
|
|
@@ -905,6 +909,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
905
909
|
// The next push re-establishes it; until then our side reports as unknown,
|
|
906
910
|
// which is honest rather than wrong.
|
|
907
911
|
writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
|
|
912
|
+
pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
|
|
908
913
|
// Per-item identity for the next push. Without it the backend reads our
|
|
909
914
|
// records as new and re-mints every page and section row.
|
|
910
915
|
writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
|
|
@@ -1216,7 +1221,13 @@ export async function pull(args = [], deps = {}) {
|
|
|
1216
1221
|
// Records this pull did not place were not taken, so neither is their lane.
|
|
1217
1222
|
if (lane === folderLane && recordsNotPlaced) continue
|
|
1218
1223
|
mergeBaseVersions(siteDir, client.origin, lane.versions)
|
|
1219
|
-
|
|
1224
|
+
}
|
|
1225
|
+
// ⭐ The site-content items' versions REPLACE what this copy held: a pull is how it
|
|
1226
|
+
// sees the site, and a version kept for an item the site no longer has would be sent
|
|
1227
|
+
// again, asking the backend to delete it. The folder lane's are not kept — that lane
|
|
1228
|
+
// is gated by its entity version. (Both were merged into one map until 2026-10-07.)
|
|
1229
|
+
if (pulledVersions && content && !content.notModified && !content.refused) {
|
|
1230
|
+
writeItemBaseVersions(siteDir, client.origin, pulledVersions)
|
|
1220
1231
|
}
|
|
1221
1232
|
// Persist the ETags so the next pull is conditional (304 when unchanged). The
|
|
1222
1233
|
// folder's is DROPPED when records were not placed, so the next pull fetches
|
package/src/commands/push.js
CHANGED
|
@@ -74,7 +74,8 @@ import {
|
|
|
74
74
|
syncedElsewhere,
|
|
75
75
|
describeSyncedElsewhere
|
|
76
76
|
} from '../utils/site-identity.js'
|
|
77
|
-
import { confirm } from '../utils/interactive.js'
|
|
77
|
+
import { confirm, isNonInteractive } from '../utils/interactive.js'
|
|
78
|
+
import { announceServices, foundationSupports } from '../backend/service-request.js'
|
|
78
79
|
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
79
80
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
80
81
|
import {
|
|
@@ -520,6 +521,10 @@ export async function push(args = [], deps = {}) {
|
|
|
520
521
|
ref: siteYml?.foundation
|
|
521
522
|
})
|
|
522
523
|
}
|
|
524
|
+
// ⭐ THE SERVICES `site.yml` ASKS FOR are stated by the emit — the file's, and off for
|
|
525
|
+
// each held one it no longer lists (`statedServices`) — and the backend decides per
|
|
526
|
+
// service. Said here: what the file asks that will not be sent as written.
|
|
527
|
+
announceServices({ siteYml, say: { warn }, supports: await foundationSupports(siteDir, siteYml) })
|
|
523
528
|
const emitOptions = {
|
|
524
529
|
backend: client.origin,
|
|
525
530
|
// Placement identity for the folder — see writeFolderItemUuids.
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-07T11:39:54.164Z",
|
|
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.78.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",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"deps": []
|
|
118
118
|
},
|
|
119
119
|
"@uniweb/templates": {
|
|
120
|
-
"version": "0.
|
|
120
|
+
"version": "0.18.0",
|
|
121
121
|
"path": "framework/templates",
|
|
122
122
|
"deps": []
|
|
123
123
|
},
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"deps": []
|
|
128
128
|
},
|
|
129
129
|
"@uniweb/unipress": {
|
|
130
|
-
"version": "0.10.
|
|
130
|
+
"version": "0.10.22",
|
|
131
131
|
"path": "framework/unipress",
|
|
132
132
|
"deps": [
|
|
133
133
|
"@uniweb/build",
|