uniweb 0.59.2 → 0.61.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.
@@ -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: BackendClient. Origin from --backend > UNIWEB_REGISTER_URL
24
- * > default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
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
@@ -32,14 +33,12 @@
32
33
  * FIRST publish of a site reads it — that create is
33
34
  * what decides which org owns the site and whose
34
35
  * storage its assets are charged to. It is then
35
- * recorded as `site.yml::$org` and replayed, so it
36
+ * recorded in `sync.json` and replayed, so it
36
37
  * never has to be re-typed.
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 Skip the deploy.yml lastDeploy auto-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
- resolveTarget,
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 { resolveSiteDir, resolveSiteBackend } from './deploy.js'
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
- assertSiteBackendScope,
76
- readSiteIdentity
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 { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
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 lastDeploy memory (skipped on --no-save / autoSave 'off').
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.autoSave === 'off') return
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
- // The site's deploy.yml-bound backend (where it was published) feeds the
257
- // resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
258
- // The project's own statement of where its identity lives. Feeds the origin ladder
259
- // ABOVE the session (see resolveBackendOrigin), so a teammate who cloned this project
260
- // targets the backend it is bound to instead of whatever they last logged into.
261
- // ⛔ The RAW value, never `resolveSiteScope` — null must mean "defer to the next tier".
262
- const siteScope = readSiteIdentity(siteDir).backend
263
- const siteBackend = await resolveSiteBackend(siteDir)
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
- // ⛔ SCOPE CHECK — before the foundation bring-along, the sync, or the go-live. A
276
- // publish is the longest of these flows and the most expensive to unwind, so it is
277
- // the one that most benefits from failing at the first step rather than at the fifth.
278
- const scope = assertSiteBackendScope(siteDir, client.origin)
279
- if (!scope.ok) {
280
- say.err(scope.message)
281
- scope.hint.forEach(say.dim)
282
- return { exitCode: 1 }
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). No --target on publish — it
345
- // always targets Uniweb hosting; resolveTarget gives us the target name +
346
- // autoSave for the lastDeploy memo.
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
- // No --target on publish — it always targets Uniweb hosting; resolveTarget
352
- // returns the uniweb default (fromFile:false) when there's no deploy.yml, so
353
- // persistLastDeploy scaffolds the file as the "where it's deployed" record.
354
- resolved = resolveTarget(deployYml, null)
355
- // The last request we are known to have sent, for the declaration gate below.
356
- // Read from the SAME deploy.yml load — one read, and the memo is the only
357
- // durable record of it (see backend/service-request.js for why not the cache).
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
- autoSave: 'lastDeploy',
390
+ saveDeploys: true,
369
391
  fromFile: false
370
392
  }
371
393
  }
372
- const autoSave = noSave ? 'off' : resolved.autoSave || 'lastDeploy'
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 : ${siteYml.$uuid || '(none — the site is created before anything uploads)'}`
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(', ')}`)
@@ -513,12 +535,18 @@ export async function publish(args = []) {
513
535
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
514
536
  const resolveModel = makeModelResolver({ client, offline: false })
515
537
 
516
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
517
- // destructive — a placeholder file, created meaning to fill it in — so the count
518
- // is reported and confirmed before anything is sent.
538
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
539
+ // is destructive — a directory emptied by accident, or kept with only a
540
+ // placeholder — so the count is reported and confirmed before anything is sent.
541
+ //
542
+ // ⛔ `backend` WAS MISSING HERE until 2026-09-21, and it is what the count is read
543
+ // under: placements are banked per backend, so with none the guard counted zero
544
+ // and never asked — `publish` sent an empty folder without a word while `push`
545
+ // stopped for the same one.
519
546
  {
520
547
  const guard = await guardEmptyRecords({
521
548
  siteDir,
549
+ backend: client.origin,
522
550
  args,
523
551
  warn: say.warn,
524
552
  note: say.dim
@@ -531,8 +559,9 @@ export async function publish(args = []) {
531
559
  let probe
532
560
  try {
533
561
  probe = await emitSyncPackages(siteDir, {
562
+ backend: client.origin,
534
563
  // Placement identity for the folder — see writeFolderItemUuids.
535
- folderItemUuids: readFolderItemUuids(siteDir),
564
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
536
565
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
537
566
  ...(asOrg ? { org: asOrg } : {}),
538
567
  ...(foundationDir ? { foundationDir } : {}),
@@ -553,13 +582,13 @@ export async function publish(args = []) {
553
582
  // that describes one is stale — including after the documented "clear
554
583
  // `$uuid` to re-publish as a new site" recovery. Must run BEFORE the create,
555
584
  // which mints a uuid and would make the clone look bound.
556
- const droppedState = clearRemoteSyncStateIfUnbound(siteDir)
585
+ const droppedState = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
557
586
  if (droppedState.length) {
558
587
  say.dim(
559
588
  `Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
560
589
  )
561
590
  }
562
- const droppedValues = dropSiteBoundValues(siteDir)
591
+ const droppedValues = dropSiteBoundValues(siteDir, client.origin)
563
592
  if (droppedValues.length) {
564
593
  say.dim(
565
594
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
@@ -628,10 +657,10 @@ export async function publish(args = []) {
628
657
  if (Object.keys(ids).length) assetIds = ids
629
658
  // Identity into the COMMITTED map — see backend/asset-map.js. Merge, not
630
659
  // replace: this publish carries only the refs its content touched.
631
- const rec = updateAssetMap(siteDir, ids)
660
+ const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
632
661
  if (rec.written) {
633
662
  say.dim(
634
- `${ASSET_MAP_FILE} : ${rec.added.length} added, ${rec.changed.length} changed — commit it`
663
+ `${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
635
664
  )
636
665
  }
637
666
  if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
@@ -731,13 +760,13 @@ export async function publish(args = []) {
731
760
  // the pinned foundation ref and rewriting local media refs to serve URLs.
732
761
  // (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
733
762
  // collection data now lands at its serving tail, so nothing records it.)
734
- const priorHashes = readSyncCache(siteDir)
763
+ const priorHashes = readSyncCache(siteDir, client.origin)
735
764
  // publish rides the same gated push as `uniweb push`: if an app author has
736
765
  // edited since this clone last synced, the push is refused rather than
737
766
  // overwriting them, and nothing goes live. `--force` drops the precondition.
738
767
  const baseVersions = args.includes('--force')
739
768
  ? null
740
- : readBaseVersions(siteDir)
769
+ : readBaseVersions(siteDir, client.origin)
741
770
  // Per-item identity, recovered from the backend when this clone has never seen it.
742
771
  // Without it the backend re-mints every page and section row (see readItemUuids).
743
772
  const itemUuids = await ensureItemUuids({
@@ -789,15 +818,20 @@ export async function publish(args = []) {
789
818
  // network blip, a site never pushed. That is the shipped behaviour and it is safe:
790
819
  // it withholds an unchanged block and sends a changed one; it merely cannot see
791
820
  // the app's side.
792
- let declaration = decideDeclaration(siteYml, priorRequest)
821
+ // What this site is PROVISIONED with on the backend being published to.
822
+ const provisioned = readBackendState(siteDir, client.origin)
823
+ const boundUuid = provisioned.site?.uuid || null
824
+ let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
793
825
  let adopted = null
794
826
  // Before the push, so a never-synced site has no uuid and simply skips this.
795
827
  const status =
796
- typeof siteYml.$uuid === 'string' && siteYml.$uuid
797
- ? await client.siteStatus(siteYml.$uuid)
828
+ // This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
829
+ // the remote reconcile below never ran and every publish declared blind.
830
+ boundUuid
831
+ ? await client.siteStatus(boundUuid)
798
832
  : null
799
833
  if (status && Array.isArray(status.services)) {
800
- const r = reconcileRequest(siteYml, status.services, priorRequest)
834
+ const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
801
835
  if (r.action === 'none') {
802
836
  declaration = { declare: false, reason: 'in-sync' }
803
837
  } else if (r.action === 'send') {
@@ -835,7 +869,7 @@ export async function publish(args = []) {
835
869
  // for one commit. Asking is the only thing that resolves it.
836
870
  if (declaration.reason === 'conflict') {
837
871
  say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
838
- say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
872
+ say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
839
873
  say.dim(` on your site: ${describeServices(adopted)}`)
840
874
  if (isNonInteractive(args)) {
841
875
  say.dim(' Left your site as it is — run without --non-interactive to choose.')
@@ -886,7 +920,7 @@ export async function publish(args = []) {
886
920
  // site refused, where nothing of theirs changed and ours simply did not take.
887
921
  // We cannot tell those apart here, so the wording claims neither.
888
922
  say.info('Your site has different services than site.yml lists.')
889
- say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
923
+ say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
890
924
  say.dim(` on your site: ${describeServices(adopted)}`)
891
925
  // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
892
926
  // silently rewrites an authored file is the surprise this seam exists to
@@ -911,9 +945,10 @@ export async function publish(args = []) {
911
945
  let pkg
912
946
  try {
913
947
  pkg = await emitSyncPackages(siteDir, {
948
+ backend: client.origin,
914
949
  ...(declaration.declare ? {} : { declareServices: false }),
915
950
  // Placement identity for the folder — see writeFolderItemUuids.
916
- folderItemUuids: readFolderItemUuids(siteDir),
951
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
917
952
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
918
953
  ...(asOrg ? { org: asOrg } : {}),
919
954
  ...(foundationDir ? { foundationDir } : {}),
@@ -921,7 +956,7 @@ export async function publish(args = []) {
921
956
  priorHashes,
922
957
  itemUuids,
923
958
  ...(baseVersions
924
- ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) }
959
+ ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir, client.origin) }
925
960
  : {}),
926
961
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
927
962
  ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
@@ -988,7 +1023,7 @@ export async function publish(args = []) {
988
1023
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
989
1024
  if (pubRes.status === 401 || pubRes.status === 403) {
990
1025
  say.dim(
991
- "Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
1026
+ "Credentials weren't accepted — run `uniweb login` again."
992
1027
  )
993
1028
  }
994
1029
  if (body) say.dim(body.slice(0, 800))
@@ -1008,8 +1043,8 @@ export async function publish(args = []) {
1008
1043
  const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
1009
1044
 
1010
1045
  // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
1011
- // reuses the resolved target without re-asking). One identity:
1012
- // site.yml::$uuid. `released` records whether this publish shipped a new
1046
+ // reuses the resolved target without re-asking). Identity is sync.json's,
1047
+ // per backend. `released` records whether this publish shipped a new
1013
1048
  // foundation version (the bring-along, §4).
1014
1049
  // Record the ref that actually went live: the pinned `@scope/name@version`
1015
1050
  // from the bring-along when present, else the site.yml ref verbatim.
@@ -1021,13 +1056,13 @@ export async function publish(args = []) {
1021
1056
  const recordedRef = fnd.ref || siteYmlRef
1022
1057
  await persistLastDeploy(siteDir, {
1023
1058
  targetName: resolved.targetName,
1024
- // First publish scaffolds deploy.yml with the backend recorded on the
1025
- // target, binding the site to where it went live (uniweb.app, or a B2B
1026
- // backend). resolveSiteBackend reads it back on later publishes.
1059
+ // A target this file does not have yet — the first publish anywhere (the whole
1060
+ // file is scaffolded), or the first to a backend no target names (the target is
1061
+ // added beside the others) — records the backend it went live on.
1027
1062
  targetConfig: resolved.fromFile
1028
1063
  ? null
1029
1064
  : { host: 'uniweb', backend: client.origin },
1030
- autoSave,
1065
+ saveDeploys,
1031
1066
  lastDeploy: {
1032
1067
  at: new Date().toISOString(),
1033
1068
  host: 'uniweb',
@@ -1037,7 +1072,7 @@ export async function publish(args = []) {
1037
1072
  // service's `config` is opaque, so recording either verbatim would write
1038
1073
  // them into git. Absent when the file declares no block.
1039
1074
  ...(declaration.declare
1040
- ? fingerprintRequest(siteYml)
1075
+ ? fingerprintRequest(siteYml, provisioned)
1041
1076
  : {
1042
1077
  // Nothing was sent, so the base is unchanged — carry it forward
1043
1078
  // rather than dropping it, or the next publish would read "no record"