uniweb 0.59.1 → 0.60.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 +3 -3
- package/partials/agents.md +6 -4
- package/src/backend/asset-download.js +6 -4
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +12 -12
- package/src/backend/service-request.js +12 -8
- package/src/backend/site-sync.js +243 -164
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/forget.js +197 -0
- package/src/commands/publish.js +102 -73
- package/src/commands/pull.js +58 -46
- package/src/commands/push.js +69 -53
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +10 -14
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +4 -4
- package/src/index.js +100 -75
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +3 -2
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
package/src/commands/publish.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
|
|
21
21
|
* (foundation code → catalog). For a self-contained artifact, see `uniweb export`.
|
|
22
22
|
*
|
|
23
|
-
* Backend:
|
|
24
|
-
*
|
|
23
|
+
* Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
|
|
24
|
+
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
25
|
+
* No `--backend` or `--token`: switching and signing in are `uniweb login`.
|
|
25
26
|
*
|
|
26
27
|
* Usage:
|
|
27
28
|
* uniweb publish Bring the foundation along, sync, and go live
|
|
@@ -37,9 +38,7 @@
|
|
|
37
38
|
* uniweb publish --personal Own the new site personally, deliberately. Sends
|
|
38
39
|
* NO `as_org` — byte-identical to the wire before
|
|
39
40
|
* the owner prompt existed. First publish only.
|
|
40
|
-
* uniweb publish --no-save
|
|
41
|
-
* uniweb publish --backend <url> Override the backend origin
|
|
42
|
-
* uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
|
|
41
|
+
* uniweb publish --no-save Do not record this publish in deploy.yml
|
|
43
42
|
*/
|
|
44
43
|
|
|
45
44
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -50,7 +49,7 @@ import yaml from 'js-yaml'
|
|
|
50
49
|
|
|
51
50
|
import {
|
|
52
51
|
loadDeployYml,
|
|
53
|
-
|
|
52
|
+
resolvePublishTarget,
|
|
54
53
|
recordLastDeploy,
|
|
55
54
|
collectSchemalessData,
|
|
56
55
|
collectSchemalessDataAssets,
|
|
@@ -67,16 +66,18 @@ import { isSiteRelativeExtensionUrl } from '@uniweb/build'
|
|
|
67
66
|
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
68
67
|
|
|
69
68
|
import { BackendClient } from '../backend/client.js'
|
|
70
|
-
import {
|
|
69
|
+
import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
|
|
70
|
+
import { resolveSiteDir } from './deploy.js'
|
|
71
71
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
72
|
import { readFlagValue, readOrgFlag } from '../utils/args.js'
|
|
73
73
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
74
74
|
import {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
syncedElsewhere,
|
|
76
|
+
describeSyncedElsewhere
|
|
77
77
|
} from '../utils/site-identity.js'
|
|
78
78
|
import { isNonInteractive, confirm } from '../utils/interactive.js'
|
|
79
79
|
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
80
|
+
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
80
81
|
import { headProvenance } from '../utils/git.js'
|
|
81
82
|
import {
|
|
82
83
|
makeModelResolver,
|
|
@@ -92,7 +93,12 @@ import {
|
|
|
92
93
|
resolveSiteOrgForCreate
|
|
93
94
|
} from '../backend/site-sync.js'
|
|
94
95
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
95
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
updateBackendMap,
|
|
98
|
+
readBackendState,
|
|
99
|
+
carryServed,
|
|
100
|
+
SYNC_STORE_FILE
|
|
101
|
+
} from '@uniweb/build/uwx'
|
|
96
102
|
import {
|
|
97
103
|
bringFoundationAlong,
|
|
98
104
|
bringExtensionsAlong
|
|
@@ -158,7 +164,7 @@ function languagesFromSiteYml(siteYml) {
|
|
|
158
164
|
return [def, ...norm.filter((l) => l !== def)]
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
// Persist deploy.yml
|
|
167
|
+
// Persist the deploys memo in deploy.yml (skipped on --no-save / saveDeploys: false).
|
|
162
168
|
/**
|
|
163
169
|
* A one-line, human-readable account of a service request, for a terminal.
|
|
164
170
|
*
|
|
@@ -223,7 +229,7 @@ function describeServices(rows) {
|
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
async function persistLastDeploy(siteDir, opts) {
|
|
226
|
-
if (opts.
|
|
232
|
+
if (opts.saveDeploys === false) return
|
|
227
233
|
try {
|
|
228
234
|
const result = await recordLastDeploy(siteDir, opts)
|
|
229
235
|
if (result?.created)
|
|
@@ -253,33 +259,48 @@ export async function publish(args = []) {
|
|
|
253
259
|
// is not a gate.
|
|
254
260
|
await warnIfContentDoesNotConform(siteDir, { args })
|
|
255
261
|
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// ⛔ The
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
263
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
264
|
+
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
265
|
+
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
266
|
+
// that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
|
|
267
|
+
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
268
|
+
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
269
|
+
// be reached: *"We do not allow any communication with backend if the user is not
|
|
270
|
+
// logged into a backend."*
|
|
264
271
|
|
|
265
272
|
const client = new BackendClient({
|
|
266
|
-
originFlag:
|
|
267
|
-
readFlagValue(args, '--backend'),
|
|
268
|
-
siteScope,
|
|
269
|
-
siteBackend,
|
|
270
|
-
token: readFlagValue(args, '--token') || undefined,
|
|
271
273
|
args,
|
|
272
274
|
command: 'Publishing'
|
|
273
275
|
})
|
|
274
276
|
|
|
275
|
-
// ⛔
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
// ⛔ A COPY OF ANOTHER PROJECT — before the foundation bring-along, the sync, or the
|
|
278
|
+
// go-live. A plain directory copy carries sync.json, so it holds the ORIGINAL's site
|
|
279
|
+
// on this backend and would publish over it (utils/site-copies.js). A publish is the
|
|
280
|
+
// longest of these flows and the most expensive to unwind, so it is the one that
|
|
281
|
+
// most benefits from failing at the first step rather than at the fifth.
|
|
282
|
+
// *(The backend scope check that stood here went on 2026-09-20 — see push.js.)*
|
|
283
|
+
{
|
|
284
|
+
const copies = findSiteCopies(siteDir, client.origin)
|
|
285
|
+
if (copies.length) {
|
|
286
|
+
const said = describeSiteCopies(copies, client.origin, 'publish')
|
|
287
|
+
say.err(said.headline)
|
|
288
|
+
for (const line of said.lines) say.dim(line)
|
|
289
|
+
return { exitCode: 1 }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ⚠️ Logged in to a backend where this project has no site, while it has one
|
|
294
|
+
// elsewhere: following the login CREATES a second site. Say so before the owner
|
|
295
|
+
// question, which otherwise arrives with no reason attached. Not when
|
|
296
|
+
// UNIWEB_REGISTER_URL chose the backend — that is already a decision.
|
|
297
|
+
if (!process.env.UNIWEB_REGISTER_URL) {
|
|
298
|
+
const known = syncedElsewhere(siteDir, client.origin)
|
|
299
|
+
if (known) {
|
|
300
|
+
const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'publish')
|
|
301
|
+
say.info(headline)
|
|
302
|
+
for (const line of rest) say.dim(line)
|
|
303
|
+
}
|
|
283
304
|
}
|
|
284
305
|
|
|
285
306
|
// WHO will own this site, if this publish is the one that creates it. Resolved
|
|
@@ -341,35 +362,36 @@ export async function publish(args = []) {
|
|
|
341
362
|
// nothing scaffolded ever set it, it is in no public doc, and the backend
|
|
342
363
|
// may already have been ignoring it.
|
|
343
364
|
|
|
344
|
-
// deploy.yml target (the Uniweb hosting memory)
|
|
345
|
-
//
|
|
346
|
-
//
|
|
365
|
+
// deploy.yml target (the Uniweb hosting memory) — the one for the backend this
|
|
366
|
+
// publish goes to. ⭐ The target follows the backend, never the reverse: the default
|
|
367
|
+
// target only breaks a tie (resolvePublishTarget). With no deploy.yml, or no target
|
|
368
|
+
// naming this backend, persistLastDeploy adds one.
|
|
369
|
+
//
|
|
370
|
+
// ⛔ This took deploy.yml's DEFAULT target until 2026-09-21, whatever backend it was
|
|
371
|
+
// publishing to — a publish to a second backend wrote over the default target's
|
|
372
|
+
// record and compared its request against that target's fingerprint.
|
|
347
373
|
let resolved
|
|
348
374
|
let priorRequest = null
|
|
349
375
|
try {
|
|
350
376
|
const deployYml = await loadDeployYml(siteDir)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
priorRequest =
|
|
359
|
-
deployYml?.lastDeploy?.[resolved?.targetName] ||
|
|
360
|
-
deployYml?.lastDeploy?.uniweb ||
|
|
361
|
-
null
|
|
377
|
+
resolved = resolvePublishTarget(deployYml, client.origin, {
|
|
378
|
+
defaultBackend: DEFAULT_BACKEND_ORIGIN
|
|
379
|
+
})
|
|
380
|
+
// The last request we are known to have sent TO THIS BACKEND, for the declaration
|
|
381
|
+
// gate below. Read from the SAME deploy.yml load — one read, and the memo is the
|
|
382
|
+
// only durable record of it (see backend/service-request.js for why not the cache).
|
|
383
|
+
priorRequest = deployYml?.deploys?.[resolved.targetName] || null
|
|
362
384
|
} catch {
|
|
363
385
|
// Malformed/ambiguous deploy.yml — don't block the publish on the memo.
|
|
364
386
|
resolved = {
|
|
365
387
|
targetName: 'production',
|
|
366
388
|
host: 'uniweb',
|
|
367
389
|
config: {},
|
|
368
|
-
|
|
390
|
+
saveDeploys: true,
|
|
369
391
|
fromFile: false
|
|
370
392
|
}
|
|
371
393
|
}
|
|
372
|
-
const
|
|
394
|
+
const saveDeploys = !noSave && resolved.saveDeploys !== false
|
|
373
395
|
|
|
374
396
|
// A SITE-RELATIVE extension URL cannot work on Uniweb hosting: the published
|
|
375
397
|
// site ships no JS, so nothing serves that path. The request falls through to
|
|
@@ -410,7 +432,7 @@ export async function publish(args = []) {
|
|
|
410
432
|
say.info('Dry run — would bring the foundation along, sync, and go live:')
|
|
411
433
|
say.dim(`Backend : ${client.origin}`)
|
|
412
434
|
say.dim(
|
|
413
|
-
`site_uuid : ${
|
|
435
|
+
`site_uuid : ${readBackendState(siteDir, client.origin).site?.uuid || '(none — the site is created before anything uploads)'}`
|
|
414
436
|
)
|
|
415
437
|
const langs = languagesFromSiteYml(siteYml)
|
|
416
438
|
if (langs) say.dim(`Languages : ${langs.join(', ')}`)
|
|
@@ -531,8 +553,9 @@ export async function publish(args = []) {
|
|
|
531
553
|
let probe
|
|
532
554
|
try {
|
|
533
555
|
probe = await emitSyncPackages(siteDir, {
|
|
556
|
+
backend: client.origin,
|
|
534
557
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
535
|
-
folderItemUuids: readFolderItemUuids(siteDir),
|
|
558
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
536
559
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
537
560
|
...(asOrg ? { org: asOrg } : {}),
|
|
538
561
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -553,13 +576,13 @@ export async function publish(args = []) {
|
|
|
553
576
|
// that describes one is stale — including after the documented "clear
|
|
554
577
|
// `$uuid` to re-publish as a new site" recovery. Must run BEFORE the create,
|
|
555
578
|
// which mints a uuid and would make the clone look bound.
|
|
556
|
-
const droppedState = clearRemoteSyncStateIfUnbound(siteDir)
|
|
579
|
+
const droppedState = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
|
|
557
580
|
if (droppedState.length) {
|
|
558
581
|
say.dim(
|
|
559
582
|
`Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
|
|
560
583
|
)
|
|
561
584
|
}
|
|
562
|
-
const droppedValues = dropSiteBoundValues(siteDir)
|
|
585
|
+
const droppedValues = dropSiteBoundValues(siteDir, client.origin)
|
|
563
586
|
if (droppedValues.length) {
|
|
564
587
|
say.dim(
|
|
565
588
|
`Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
|
|
@@ -628,10 +651,10 @@ export async function publish(args = []) {
|
|
|
628
651
|
if (Object.keys(ids).length) assetIds = ids
|
|
629
652
|
// Identity into the COMMITTED map — see backend/asset-map.js. Merge, not
|
|
630
653
|
// replace: this publish carries only the refs its content touched.
|
|
631
|
-
const rec =
|
|
654
|
+
const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
|
|
632
655
|
if (rec.written) {
|
|
633
656
|
say.dim(
|
|
634
|
-
`${
|
|
657
|
+
`${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
|
|
635
658
|
)
|
|
636
659
|
}
|
|
637
660
|
if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
|
|
@@ -731,13 +754,13 @@ export async function publish(args = []) {
|
|
|
731
754
|
// the pinned foundation ref and rewriting local media refs to serve URLs.
|
|
732
755
|
// (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
|
|
733
756
|
// collection data now lands at its serving tail, so nothing records it.)
|
|
734
|
-
const priorHashes = readSyncCache(siteDir)
|
|
757
|
+
const priorHashes = readSyncCache(siteDir, client.origin)
|
|
735
758
|
// publish rides the same gated push as `uniweb push`: if an app author has
|
|
736
759
|
// edited since this clone last synced, the push is refused rather than
|
|
737
760
|
// overwriting them, and nothing goes live. `--force` drops the precondition.
|
|
738
761
|
const baseVersions = args.includes('--force')
|
|
739
762
|
? null
|
|
740
|
-
: readBaseVersions(siteDir)
|
|
763
|
+
: readBaseVersions(siteDir, client.origin)
|
|
741
764
|
// Per-item identity, recovered from the backend when this clone has never seen it.
|
|
742
765
|
// Without it the backend re-mints every page and section row (see readItemUuids).
|
|
743
766
|
const itemUuids = await ensureItemUuids({
|
|
@@ -789,15 +812,20 @@ export async function publish(args = []) {
|
|
|
789
812
|
// network blip, a site never pushed. That is the shipped behaviour and it is safe:
|
|
790
813
|
// it withholds an unchanged block and sends a changed one; it merely cannot see
|
|
791
814
|
// the app's side.
|
|
792
|
-
|
|
815
|
+
// What this site is PROVISIONED with on the backend being published to.
|
|
816
|
+
const provisioned = readBackendState(siteDir, client.origin)
|
|
817
|
+
const boundUuid = provisioned.site?.uuid || null
|
|
818
|
+
let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
|
|
793
819
|
let adopted = null
|
|
794
820
|
// Before the push, so a never-synced site has no uuid and simply skips this.
|
|
795
821
|
const status =
|
|
796
|
-
|
|
797
|
-
|
|
822
|
+
// This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
|
|
823
|
+
// the remote reconcile below never ran and every publish declared blind.
|
|
824
|
+
boundUuid
|
|
825
|
+
? await client.siteStatus(boundUuid)
|
|
798
826
|
: null
|
|
799
827
|
if (status && Array.isArray(status.services)) {
|
|
800
|
-
const r = reconcileRequest(siteYml, status.services, priorRequest)
|
|
828
|
+
const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
|
|
801
829
|
if (r.action === 'none') {
|
|
802
830
|
declaration = { declare: false, reason: 'in-sync' }
|
|
803
831
|
} else if (r.action === 'send') {
|
|
@@ -835,7 +863,7 @@ export async function publish(args = []) {
|
|
|
835
863
|
// for one commit. Asking is the only thing that resolves it.
|
|
836
864
|
if (declaration.reason === 'conflict') {
|
|
837
865
|
say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
|
|
838
|
-
say.dim(` in
|
|
866
|
+
say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
|
|
839
867
|
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
840
868
|
if (isNonInteractive(args)) {
|
|
841
869
|
say.dim(' Left your site as it is — run without --non-interactive to choose.')
|
|
@@ -886,7 +914,7 @@ export async function publish(args = []) {
|
|
|
886
914
|
// site refused, where nothing of theirs changed and ours simply did not take.
|
|
887
915
|
// We cannot tell those apart here, so the wording claims neither.
|
|
888
916
|
say.info('Your site has different services than site.yml lists.')
|
|
889
|
-
say.dim(` in
|
|
917
|
+
say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
|
|
890
918
|
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
891
919
|
// ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
|
|
892
920
|
// silently rewrites an authored file is the surprise this seam exists to
|
|
@@ -911,9 +939,10 @@ export async function publish(args = []) {
|
|
|
911
939
|
let pkg
|
|
912
940
|
try {
|
|
913
941
|
pkg = await emitSyncPackages(siteDir, {
|
|
942
|
+
backend: client.origin,
|
|
914
943
|
...(declaration.declare ? {} : { declareServices: false }),
|
|
915
944
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
916
|
-
folderItemUuids: readFolderItemUuids(siteDir),
|
|
945
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
917
946
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
918
947
|
...(asOrg ? { org: asOrg } : {}),
|
|
919
948
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -921,7 +950,7 @@ export async function publish(args = []) {
|
|
|
921
950
|
priorHashes,
|
|
922
951
|
itemUuids,
|
|
923
952
|
...(baseVersions
|
|
924
|
-
? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
|
|
953
|
+
? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir, client.origin) }
|
|
925
954
|
: {}),
|
|
926
955
|
...(Object.keys(injectInfo).length ? { injectInfo } : {}),
|
|
927
956
|
...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
|
|
@@ -988,7 +1017,7 @@ export async function publish(args = []) {
|
|
|
988
1017
|
say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
|
|
989
1018
|
if (pubRes.status === 401 || pubRes.status === 403) {
|
|
990
1019
|
say.dim(
|
|
991
|
-
"Credentials weren't accepted — run `uniweb login`
|
|
1020
|
+
"Credentials weren't accepted — run `uniweb login` again."
|
|
992
1021
|
)
|
|
993
1022
|
}
|
|
994
1023
|
if (body) say.dim(body.slice(0, 800))
|
|
@@ -1008,8 +1037,8 @@ export async function publish(args = []) {
|
|
|
1008
1037
|
const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
|
|
1009
1038
|
|
|
1010
1039
|
// 8. Persist deploy.yml memory — a record of what went live (and so a re-run
|
|
1011
|
-
// reuses the resolved target without re-asking).
|
|
1012
|
-
//
|
|
1040
|
+
// reuses the resolved target without re-asking). Identity is sync.json's,
|
|
1041
|
+
// per backend. `released` records whether this publish shipped a new
|
|
1013
1042
|
// foundation version (the bring-along, §4).
|
|
1014
1043
|
// Record the ref that actually went live: the pinned `@scope/name@version`
|
|
1015
1044
|
// from the bring-along when present, else the site.yml ref verbatim.
|
|
@@ -1021,13 +1050,13 @@ export async function publish(args = []) {
|
|
|
1021
1050
|
const recordedRef = fnd.ref || siteYmlRef
|
|
1022
1051
|
await persistLastDeploy(siteDir, {
|
|
1023
1052
|
targetName: resolved.targetName,
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1053
|
+
// A target this file does not have yet — the first publish anywhere (the whole
|
|
1054
|
+
// file is scaffolded), or the first to a backend no target names (the target is
|
|
1055
|
+
// added beside the others) — records the backend it went live on.
|
|
1027
1056
|
targetConfig: resolved.fromFile
|
|
1028
1057
|
? null
|
|
1029
1058
|
: { host: 'uniweb', backend: client.origin },
|
|
1030
|
-
|
|
1059
|
+
saveDeploys,
|
|
1031
1060
|
lastDeploy: {
|
|
1032
1061
|
at: new Date().toISOString(),
|
|
1033
1062
|
host: 'uniweb',
|
|
@@ -1037,7 +1066,7 @@ export async function publish(args = []) {
|
|
|
1037
1066
|
// service's `config` is opaque, so recording either verbatim would write
|
|
1038
1067
|
// them into git. Absent when the file declares no block.
|
|
1039
1068
|
...(declaration.declare
|
|
1040
|
-
? fingerprintRequest(siteYml)
|
|
1069
|
+
? fingerprintRequest(siteYml, provisioned)
|
|
1041
1070
|
: {
|
|
1042
1071
|
// Nothing was sent, so the base is unchanged — carry it forward
|
|
1043
1072
|
// rather than dropping it, or the next publish would read "no record"
|
package/src/commands/pull.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* uniweb pull — bring the backend's copy of a site back to canonical files.
|
|
3
3
|
*
|
|
4
|
-
* The read-side mirror of `uniweb push`. The project
|
|
5
|
-
*
|
|
4
|
+
* The read-side mirror of `uniweb push`. The project's identity on a backend — its
|
|
5
|
+
* site-content uuid, in that backend's section of `sync.json` — keys BOTH pull lanes
|
|
6
6
|
* (the backend owns the site's `@uniweb/folder` and resolves it from the site-content
|
|
7
7
|
* uuid, so the framework never holds a folder uuid). It GETs the two lanes and projects
|
|
8
8
|
* the returned documents back to files via the framework's projection layer
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
* uniweb pull --merge Three-way merge local changes with the backend's
|
|
42
42
|
* uniweb pull --force Pull over uncommitted local changes (discards them)
|
|
43
43
|
* uniweb pull --dry-run Report what it would GET; write nothing
|
|
44
|
-
* uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
|
|
45
44
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* Auth:
|
|
45
|
+
* The content + folder pull lanes are both keyed by the site's uuid on that backend
|
|
46
|
+
* (sync.json).
|
|
47
|
+
* Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
|
|
48
|
+
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
49
|
+
* No `--backend` or `--token`: switching and signing in are `uniweb login`.
|
|
50
50
|
*
|
|
51
51
|
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
52
52
|
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
@@ -69,6 +69,7 @@ import { join, dirname, relative } from 'node:path'
|
|
|
69
69
|
import yaml from 'js-yaml'
|
|
70
70
|
import { downloadMissingAssets } from '../backend/asset-download.js'
|
|
71
71
|
import {
|
|
72
|
+
readBackendState,
|
|
72
73
|
siteContentDocumentToProject,
|
|
73
74
|
recordsToProject,
|
|
74
75
|
readZip,
|
|
@@ -98,14 +99,10 @@ import {
|
|
|
98
99
|
} from '../utils/git.js'
|
|
99
100
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
100
101
|
import { BackendClient } from '../backend/client.js'
|
|
101
|
-
import {
|
|
102
|
-
resolveSiteDir as defaultResolveSiteDir,
|
|
103
|
-
resolveSiteBackend
|
|
104
|
-
} from './deploy.js'
|
|
102
|
+
import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
|
|
105
103
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
106
104
|
import {
|
|
107
|
-
|
|
108
|
-
readSiteIdentity
|
|
105
|
+
syncedElsewhere
|
|
109
106
|
} from '../utils/site-identity.js'
|
|
110
107
|
|
|
111
108
|
const FOLDER_MODEL = '@uniweb/folder'
|
|
@@ -188,7 +185,8 @@ function readYamlUuid(filePath) {
|
|
|
188
185
|
} catch (err) {
|
|
189
186
|
return { uuid: null, unparsed: err.message.split('\n')[0] }
|
|
190
187
|
}
|
|
191
|
-
|
|
188
|
+
// Only whether site.yml parses. The uuid itself lives in sync.json now.
|
|
189
|
+
return { uuid: null }
|
|
192
190
|
}
|
|
193
191
|
|
|
194
192
|
// Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
|
|
@@ -540,7 +538,6 @@ export async function pull(args = [], deps = {}) {
|
|
|
540
538
|
const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
|
|
541
539
|
|
|
542
540
|
const dryRun = args.includes('--dry-run')
|
|
543
|
-
const tokenFlag = flagValue(args, '--token')
|
|
544
541
|
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
545
542
|
const noRecords =
|
|
546
543
|
args.includes('--no-records') || args.includes('--content-only')
|
|
@@ -579,35 +576,28 @@ export async function pull(args = [], deps = {}) {
|
|
|
579
576
|
const blocked = await checkWorkingTree(siteDir, args)
|
|
580
577
|
if (blocked) return blocked
|
|
581
578
|
}
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
579
|
+
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
580
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
581
|
+
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
582
|
+
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
583
|
+
// that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
|
|
584
|
+
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
585
|
+
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
586
|
+
// be reached: *"We do not allow any communication with backend if the user is not
|
|
587
|
+
// logged into a backend."*
|
|
588
588
|
const client = new BackendClient({
|
|
589
|
-
originFlag: flagValue(args, '--backend'),
|
|
590
|
-
siteScope,
|
|
591
|
-
siteBackend,
|
|
592
|
-
token: tokenFlag,
|
|
593
589
|
getToken: deps.getToken,
|
|
594
590
|
fetchImpl: deps.fetch,
|
|
595
591
|
args,
|
|
596
592
|
command: 'Pulling'
|
|
597
593
|
})
|
|
598
594
|
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
const scope = assertSiteBackendScope(siteDir, client.origin)
|
|
603
|
-
if (!scope.ok) {
|
|
604
|
-
error(scope.message)
|
|
605
|
-
scope.hint.forEach(note)
|
|
606
|
-
return { exitCode: 1 }
|
|
607
|
-
}
|
|
595
|
+
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
596
|
+
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
597
|
+
// wrong-backend pull cannot happen: it would find no uuid and stop below.
|
|
608
598
|
|
|
609
|
-
// One identity per site
|
|
610
|
-
//
|
|
599
|
+
// One identity per site per backend. Both lanes (content + folder) are keyed by it —
|
|
600
|
+
// the backend resolves the site's `@uniweb/folder` from this uuid.
|
|
611
601
|
const siteYmlPath = join(siteDir, 'site.yml')
|
|
612
602
|
const identity = readYamlUuid(siteYmlPath)
|
|
613
603
|
|
|
@@ -621,11 +611,28 @@ export async function pull(args = [], deps = {}) {
|
|
|
621
611
|
return { exitCode: 1 }
|
|
622
612
|
}
|
|
623
613
|
|
|
624
|
-
|
|
614
|
+
// ⛔ FROM sync.json, FOR THIS BACKEND. This read `identity.uuid` — site.yml's `$uuid`
|
|
615
|
+
// — and kept doing so after step 4 moved the key, so EVERY pull of a synced project
|
|
616
|
+
// answered "Nothing to pull — this project has no $uuid yet". site.yml is still
|
|
617
|
+
// parsed above, because a file that will not parse is its own error.
|
|
618
|
+
const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
|
|
625
619
|
|
|
626
620
|
if (!siteContentUuid) {
|
|
621
|
+
// Following the login can land on a backend with no site for this project while it
|
|
622
|
+
// has one elsewhere — say where, rather than only "push first", which would create a
|
|
623
|
+
// second site. Not when UNIWEB_REGISTER_URL chose the backend: that is a decision.
|
|
624
|
+
const known = !process.env.UNIWEB_REGISTER_URL
|
|
625
|
+
? syncedElsewhere(siteDir, client.origin)
|
|
626
|
+
: null
|
|
627
|
+
if (known) {
|
|
628
|
+
info(`Nothing to pull — this project has no site on ${client.origin}.`)
|
|
629
|
+
note(
|
|
630
|
+
`Its ${known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`}. To pull from there: uniweb login --backend <url>`
|
|
631
|
+
)
|
|
632
|
+
return { exitCode: 0 }
|
|
633
|
+
}
|
|
627
634
|
info(
|
|
628
|
-
|
|
635
|
+
`Nothing to pull — this project has not synced with ${client.origin} yet. Run \`uniweb push\` first.`
|
|
629
636
|
)
|
|
630
637
|
return { exitCode: 0 }
|
|
631
638
|
}
|
|
@@ -652,7 +659,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
652
659
|
res = await doRequest()
|
|
653
660
|
} catch (err) {
|
|
654
661
|
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
655
|
-
note('
|
|
662
|
+
note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
|
|
656
663
|
return null
|
|
657
664
|
}
|
|
658
665
|
if (res.status === 404) {
|
|
@@ -667,7 +674,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
667
674
|
error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
|
|
668
675
|
if (res.status === 401 || res.status === 403)
|
|
669
676
|
note(
|
|
670
|
-
"Credentials weren't accepted —
|
|
677
|
+
"Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
|
|
671
678
|
)
|
|
672
679
|
return null
|
|
673
680
|
}
|
|
@@ -678,8 +685,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
678
685
|
// Bank the per-entity staleness tokens this lane carried, so the next push
|
|
679
686
|
// is gated against the state we just took. A 304 skips this — correctly:
|
|
680
687
|
// unchanged upstream means the token we already hold is still current.
|
|
681
|
-
mergeBaseVersions(siteDir, readPullVersions(buf))
|
|
682
|
-
mergeItemBaseVersions(siteDir, readPullItemVersions(buf))
|
|
688
|
+
mergeBaseVersions(siteDir, client.origin, readPullVersions(buf))
|
|
689
|
+
mergeItemBaseVersions(siteDir, client.origin, readPullItemVersions(buf))
|
|
683
690
|
return { docs, etag }
|
|
684
691
|
} catch (err) {
|
|
685
692
|
error(`Could not read the ${label} response: ${err.message}`)
|
|
@@ -721,14 +728,14 @@ export async function pull(args = [], deps = {}) {
|
|
|
721
728
|
// byte-identical to it, so the old local base no longer describes anything.
|
|
722
729
|
// The next push re-establishes it; until then our side reports as unknown,
|
|
723
730
|
// which is honest rather than wrong.
|
|
724
|
-
writeUnitBases(siteDir, { remote: computeUnitHashes(siteDoc), local: {} })
|
|
731
|
+
writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
|
|
725
732
|
// Per-item identity for the next push. Without it the backend reads our
|
|
726
733
|
// records as new and re-mints every page and section row.
|
|
727
|
-
writeItemUuids(siteDir, collectUnitUuids(siteDoc))
|
|
734
|
+
writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
|
|
728
735
|
// The collections section's identity has no file to live in either — same
|
|
729
736
|
// reason, same remedy, keyed by name. A pull is the other route by which a
|
|
730
737
|
// copy can recover it (see readQueryUuids).
|
|
731
|
-
writeQueryUuids(siteDir, collectQueryUuids(siteDoc))
|
|
738
|
+
writeQueryUuids(siteDir, client.origin, collectQueryUuids(siteDoc))
|
|
732
739
|
// Bring the media down BEFORE projecting: a newly-landed asset gains a map
|
|
733
740
|
// entry, and the projection reads that map to put authored paths back. Run
|
|
734
741
|
// after, and this pull's new assets would project as URLs and only restore
|
|
@@ -795,6 +802,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
795
802
|
const report = siteContentDocumentToProject({
|
|
796
803
|
document: siteDoc,
|
|
797
804
|
siteRoot: siteDir,
|
|
805
|
+
// Which backend's asset ids to read back as the author's own paths. Without
|
|
806
|
+
// it a pull leaves every image pointing at a backend route.
|
|
807
|
+
backend: client.origin,
|
|
798
808
|
prune,
|
|
799
809
|
keepAuthoredFoundation
|
|
800
810
|
})
|
|
@@ -841,6 +851,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
841
851
|
recordDocs,
|
|
842
852
|
siteRoot: siteDir,
|
|
843
853
|
opts: {
|
|
854
|
+
// Whose record map to read and extend, and whose org to place models under.
|
|
855
|
+
backend: client.origin,
|
|
844
856
|
resolveDeclaration: (name) => declByModel.get(name) || null
|
|
845
857
|
}
|
|
846
858
|
})
|
|
@@ -905,7 +917,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
905
917
|
// wrong content, and must not fail a pull whose files are already written.
|
|
906
918
|
if (!dryRun) {
|
|
907
919
|
try {
|
|
908
|
-
await rebankSyncHashes(siteDir)
|
|
920
|
+
await rebankSyncHashes(siteDir, client.origin)
|
|
909
921
|
} catch (err) {
|
|
910
922
|
note(`! could not re-bank the sync cache: ${err.message}`)
|
|
911
923
|
note(' The next push will re-send content that is already current.')
|