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.
- package/package.json +7 -7
- package/partials/agents.md +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- 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/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- 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/templates/site/site.yml.hbs +11 -9
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
|
})
|
|
@@ -833,18 +843,22 @@ export async function pull(args = [], deps = {}) {
|
|
|
833
843
|
}
|
|
834
844
|
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
|
|
835
845
|
// `$model` names the pool folder — not by any query that happens to select
|
|
836
|
-
// it. `recordsToProject` reads
|
|
837
|
-
// model the producer resolved to `@org/x` is placed
|
|
838
|
-
// wrote it.
|
|
846
|
+
// it. `recordsToProject` reads the site's org itself (this backend's, from
|
|
847
|
+
// `sync.json`), so a `@/x` model the producer resolved to `@org/x` is placed
|
|
848
|
+
// back where the author wrote it.
|
|
839
849
|
const report = recordsToProject({
|
|
840
850
|
folderDoc,
|
|
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
|
})
|
|
847
859
|
if (report.records === 'updated') info('Wrote records.yml')
|
|
860
|
+
// The backend's folder has no sub-folders, and records.yml holds nothing else.
|
|
861
|
+
else if (report.records === 'removed') info('Removed records.yml — the folder has no sub-folders')
|
|
848
862
|
records += report.placed.length + report.updated.length
|
|
849
863
|
for (const s of report.skipped)
|
|
850
864
|
note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
@@ -905,7 +919,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
905
919
|
// wrong content, and must not fail a pull whose files are already written.
|
|
906
920
|
if (!dryRun) {
|
|
907
921
|
try {
|
|
908
|
-
await rebankSyncHashes(siteDir)
|
|
922
|
+
await rebankSyncHashes(siteDir, client.origin)
|
|
909
923
|
} catch (err) {
|
|
910
924
|
note(`! could not re-bank the sync cache: ${err.message}`)
|
|
911
925
|
note(' The next push will re-send content that is already current.')
|
package/src/commands/push.js
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
* - folder lane → one `@uniweb/folder` + the collection-record entities it
|
|
6
6
|
* references (the dynamic half; the `$ref` closure rides together).
|
|
7
7
|
*
|
|
8
|
-
* Each entity is an entity-content document (`$id` + `$model` + sections). The site
|
|
9
|
-
*
|
|
10
|
-
* push has none — it CREATEs the site (uuid-less),
|
|
11
|
-
* and returns the new uuid, which `push` records
|
|
8
|
+
* Each entity is an entity-content document (`$id` + `$model` + sections). The site's
|
|
9
|
+
* identity on a backend is its site-content uuid, in that backend's section of
|
|
10
|
+
* `sync.json`. A first push to a backend has none — it CREATEs the site (uuid-less),
|
|
11
|
+
* the backend mints + adopts it and returns the new uuid, which `push` records in
|
|
12
|
+
* `sync.json`. Later pushes
|
|
12
13
|
* UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
|
|
13
14
|
* the backend owns the site's `@uniweb/folder`, so the framework never holds a
|
|
14
15
|
* folder uuid. Records still round-trip their own `$uuid`
|
|
15
16
|
* (back-filled into their source files). site-content items carry a per-item `$uuid`
|
|
16
|
-
* too, stamped at emit from
|
|
17
|
+
* too, stamped at emit from `sync.json` rather than from author files — without
|
|
17
18
|
* it the backend reads every record as new and recreates every page and section row.
|
|
18
19
|
* Push-only, and gated on the backend's per-entity `version`
|
|
19
20
|
* (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
|
|
@@ -29,15 +30,14 @@
|
|
|
29
30
|
* uniweb push --org @org Own the new site under @org (alias: --as-org).
|
|
30
31
|
* Read only on the FIRST push of a site — it
|
|
31
32
|
* decides which org owns it, and whose storage
|
|
32
|
-
* its assets are charged to. Recorded
|
|
33
|
-
* `
|
|
33
|
+
* its assets are charged to. Recorded in
|
|
34
|
+
* `sync.json` and replayed after that.
|
|
34
35
|
* Without it, you are asked once.
|
|
35
36
|
* uniweb push --personal Own the new site personally, deliberately.
|
|
36
37
|
* Sends NO `as_org` — the same wire as before
|
|
37
38
|
* this prompt existed. First push only.
|
|
38
39
|
* uniweb push --dry-run Report what would be pushed; submit nothing
|
|
39
40
|
* uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
|
|
40
|
-
* uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
|
|
41
41
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
42
42
|
* uniweb push --all Send every record (bypass the changed-only cache)
|
|
43
43
|
* uniweb push --force Overwrite upstream changes (drop the staleness gate)
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
* backend's reconcile deletes items absent from the package, so an author's NEW page
|
|
50
50
|
* would be hard-deleted). `--force` omits the token and restores last-push-wins.
|
|
51
51
|
*
|
|
52
|
-
* Backend:
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Backend: the one you are logged in to — UNIWEB_REGISTER_URL overrides it for a script
|
|
53
|
+
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
54
|
+
* No `--backend` or `--token`: switching and signing in are `uniweb login`.
|
|
55
55
|
*
|
|
56
56
|
* The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
|
|
57
57
|
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
|
|
@@ -63,17 +63,18 @@ import { readFileSync, writeFileSync } from 'node:fs'
|
|
|
63
63
|
import { join, resolve } from 'node:path'
|
|
64
64
|
import yaml from 'js-yaml'
|
|
65
65
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
66
|
+
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
66
67
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
67
|
-
import {
|
|
68
|
+
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
68
69
|
import { BackendClient } from '../backend/client.js'
|
|
69
|
-
import { resolveSiteDir
|
|
70
|
+
import { resolveSiteDir } from './deploy.js'
|
|
70
71
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
71
72
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
72
73
|
import { readOrgFlag } from '../utils/args.js'
|
|
73
74
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
74
75
|
import {
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
syncedElsewhere,
|
|
77
|
+
describeSyncedElsewhere
|
|
77
78
|
} from '../utils/site-identity.js'
|
|
78
79
|
import { confirm } from '../utils/interactive.js'
|
|
79
80
|
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
@@ -141,7 +142,6 @@ export async function push(args = [], deps = {}) {
|
|
|
141
142
|
}
|
|
142
143
|
const dryRun = args.includes('--dry-run')
|
|
143
144
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
144
|
-
const tokenFlag = flagValue(args, '--token')
|
|
145
145
|
const foundationDir = flagValue(args, '--foundation')
|
|
146
146
|
const sendAll = args.includes('--all') // bypass the send-only-changed cache
|
|
147
147
|
// --force drops the optimistic-concurrency precondition, making the push
|
|
@@ -156,50 +156,64 @@ export async function push(args = [], deps = {}) {
|
|
|
156
156
|
// Advisory only — warns and pushes. A malformed data block otherwise rides
|
|
157
157
|
// the sync wire unchecked; see utils/conformance.js.
|
|
158
158
|
await warnIfContentDoesNotConform(siteDir, { args })
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
160
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
161
|
+
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
162
|
+
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
163
|
+
// that login. ⛔ There is no `--backend` here: switching is `uniweb login --backend`.
|
|
164
|
+
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
165
|
+
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
166
|
+
// be reached: *"We do not allow any communication with backend if the user is not
|
|
167
|
+
// logged into a backend."*
|
|
165
168
|
// One front door. The bearer is resolved lazily on first need (a non-local Model
|
|
166
169
|
// read during the build, or the submit). Offline emit (--dry-run / -o) is fully
|
|
167
170
|
// offline: it never submits, and its Model resolver never reads from the backend
|
|
168
171
|
// (the `offline` flag below), so it never authenticates — even when a collection
|
|
169
172
|
// references a Model the local foundation doesn't define.
|
|
170
173
|
const client = new BackendClient({
|
|
171
|
-
originFlag: flagValue(args, '--backend'),
|
|
172
|
-
siteScope,
|
|
173
|
-
siteBackend,
|
|
174
|
-
token: tokenFlag,
|
|
175
174
|
args,
|
|
176
175
|
command: 'Syncing'
|
|
177
176
|
})
|
|
178
177
|
|
|
179
|
-
// ⛔
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
178
|
+
// ⛔ A COPY OF ANOTHER PROJECT — before anything is sent. A plain directory copy
|
|
179
|
+
// carries sync.json, so it holds the ORIGINAL's site on this backend and its push
|
|
180
|
+
// would update that site (see utils/site-copies.js for why the signal is exact).
|
|
181
|
+
// Refused, not warned: a warning printed while the push goes ahead is printed over
|
|
182
|
+
// the damage. Runs after the client so it checks the RESOLVED origin.
|
|
183
183
|
//
|
|
184
|
-
// ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// deliberately keeps offline (the `!output && !dryRun` guards below are the same rule).
|
|
188
|
-
// The refusal even says "Sending them elsewhere is refused" over a run that sends
|
|
189
|
-
// nothing.
|
|
184
|
+
// ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all — there is no
|
|
185
|
+
// site for it to update, and refusing would break an operation this command keeps
|
|
186
|
+
// offline (the `!output && !dryRun` guards below are the same rule).
|
|
190
187
|
//
|
|
191
188
|
// `--dry-run` IS checked, and the asymmetry is the point: a dry run previews a real
|
|
192
|
-
// push, so when that push would be refused, saying so is the honest preview.
|
|
193
|
-
//
|
|
189
|
+
// push, so when that push would be refused, saying so is the honest preview.
|
|
190
|
+
//
|
|
191
|
+
// *(This spot held the backend SCOPE CHECK, `assertSiteBackendScope`, until
|
|
192
|
+
// 2026-09-20 — deleted when identity became per-backend and a foreign backend's
|
|
193
|
+
// uuids stopped being reachable at all.)*
|
|
194
194
|
if (!output) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
const copies = findSiteCopies(siteDir, client.origin)
|
|
196
|
+
if (copies.length) {
|
|
197
|
+
const said = describeSiteCopies(copies, client.origin, 'push')
|
|
198
|
+
error(said.headline)
|
|
199
|
+
for (const line of said.lines) note(line)
|
|
199
200
|
return { exitCode: 1 }
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
// ⚠️ Logged in to a backend where this project has no site, while it has one
|
|
205
|
+
// elsewhere: following the login CREATES a second site. Say so before the owner
|
|
206
|
+
// question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
|
|
207
|
+
// created) and not when UNIWEB_REGISTER_URL chose the backend — that is a decision.
|
|
208
|
+
if (!output && !process.env.UNIWEB_REGISTER_URL) {
|
|
209
|
+
const known = syncedElsewhere(siteDir, client.origin)
|
|
210
|
+
if (known) {
|
|
211
|
+
const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'push')
|
|
212
|
+
info(headline)
|
|
213
|
+
for (const line of rest) note(line)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
// WHO will own this site, if this push is the one that creates it. Resolved
|
|
204
218
|
// before any lane runs, because both create paths below consume it and neither
|
|
205
219
|
// should be reached with the question still open. A site that already exists
|
|
@@ -298,18 +312,18 @@ export async function push(args = [], deps = {}) {
|
|
|
298
312
|
//
|
|
299
313
|
// Still skipped for an offline emit: `-o` / `--dry-run` must not mutate project state.
|
|
300
314
|
if (!output && !dryRun) {
|
|
301
|
-
const dropped = clearRemoteSyncStateIfUnbound(siteDir)
|
|
315
|
+
const dropped = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
|
|
302
316
|
if (dropped.length) {
|
|
303
317
|
note(
|
|
304
318
|
`Cleared stale sync state from a previous site (${dropped.join(', ')}).`
|
|
305
319
|
)
|
|
306
320
|
}
|
|
307
|
-
const stale = dropSiteBoundValues(siteDir)
|
|
321
|
+
const stale = dropSiteBoundValues(siteDir, client.origin)
|
|
308
322
|
if (stale.length) {
|
|
309
323
|
note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
|
|
310
324
|
}
|
|
311
325
|
}
|
|
312
|
-
const priorHashes = readSyncCache(siteDir)
|
|
326
|
+
const priorHashes = readSyncCache(siteDir, client.origin)
|
|
313
327
|
// Per-item identity, without which the backend reads every record as new and
|
|
314
328
|
// recreates every page and section row.
|
|
315
329
|
//
|
|
@@ -329,8 +343,8 @@ export async function push(args = [], deps = {}) {
|
|
|
329
343
|
// The upload runs before `ensureItemUuids`, and the reason once recorded here —
|
|
330
344
|
// "BEFORE `ensureItemUuids`, which mints uuids on the backend, so a refusal
|
|
331
345
|
// leaves nothing minted" — is FALSE. `ensureItemUuids` mints nothing: it reads a
|
|
332
|
-
// local
|
|
333
|
-
// immediately when there is none (the first-push case). Its only backend call is
|
|
346
|
+
// local map, and if that is empty it reads this backend's site uuid from sync.json
|
|
347
|
+
// and returns immediately when there is none (the first-push case). Its only backend call is
|
|
334
348
|
// a GET (`pullSiteContent`); its only write is a local file. So this ordering is
|
|
335
349
|
// not protecting what that comment claimed, and is under review — an upload is a
|
|
336
350
|
// durable, metered write, which makes uploading first the thing that leaves bytes
|
|
@@ -374,12 +388,12 @@ export async function push(args = [], deps = {}) {
|
|
|
374
388
|
// It also has to be this emit that carries `assetRewrite` below: the push cache
|
|
375
389
|
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
376
390
|
// rewrite too, or every entity reads as changed forever.
|
|
377
|
-
// ⛔ AN EMPTY
|
|
378
|
-
// destructive — a
|
|
379
|
-
// is reported and confirmed before anything is sent.
|
|
380
|
-
// the asking happens here.
|
|
391
|
+
// ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
|
|
392
|
+
// is destructive — a directory emptied by accident, or kept with only a
|
|
393
|
+
// placeholder — so the count is reported and confirmed before anything is sent.
|
|
394
|
+
// The format stays honest; the asking happens here.
|
|
381
395
|
if (!dryRun) {
|
|
382
|
-
const guard = await guardEmptyRecords({ siteDir, args, warn, note })
|
|
396
|
+
const guard = await guardEmptyRecords({ siteDir, backend: client.origin, args, warn, note })
|
|
383
397
|
if (!guard.ok) return { exitCode: 1 }
|
|
384
398
|
}
|
|
385
399
|
|
|
@@ -389,8 +403,9 @@ export async function push(args = [], deps = {}) {
|
|
|
389
403
|
let mediaRefs = []
|
|
390
404
|
try {
|
|
391
405
|
const probe = await emitSyncPackages(siteDir, {
|
|
406
|
+
backend: client.origin,
|
|
392
407
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
393
|
-
folderItemUuids: readFolderItemUuids(siteDir),
|
|
408
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
394
409
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
395
410
|
...(asOrg ? { org: asOrg } : {}),
|
|
396
411
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -458,10 +473,10 @@ export async function push(args = [], deps = {}) {
|
|
|
458
473
|
)
|
|
459
474
|
// Record identity in the COMMITTED map. Merge, never replace: this push
|
|
460
475
|
// carries only the refs its content touched.
|
|
461
|
-
const rec =
|
|
476
|
+
const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
|
|
462
477
|
if (rec.written) {
|
|
463
478
|
note(
|
|
464
|
-
`${
|
|
479
|
+
`${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
|
|
465
480
|
)
|
|
466
481
|
}
|
|
467
482
|
} catch (err) {
|
|
@@ -482,16 +497,17 @@ export async function push(args = [], deps = {}) {
|
|
|
482
497
|
|
|
483
498
|
const itemUuids =
|
|
484
499
|
output || dryRun
|
|
485
|
-
? readItemUuids(siteDir)
|
|
500
|
+
? readItemUuids(siteDir, client.origin)
|
|
486
501
|
: await ensureItemUuids({ client, siteDir, note })
|
|
487
502
|
let pkg
|
|
488
503
|
try {
|
|
489
504
|
pkg = await emitSyncPackages(siteDir, {
|
|
505
|
+
backend: client.origin,
|
|
490
506
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
491
|
-
folderItemUuids: readFolderItemUuids(siteDir),
|
|
507
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
492
508
|
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
493
509
|
// name, because a declaration has no file for a path-keyed map to hold.
|
|
494
|
-
queryUuids: readQueryUuids(siteDir),
|
|
510
|
+
queryUuids: readQueryUuids(siteDir, client.origin),
|
|
495
511
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
496
512
|
...(asOrg ? { org: asOrg } : {}),
|
|
497
513
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -513,8 +529,8 @@ export async function push(args = [], deps = {}) {
|
|
|
513
529
|
...(force
|
|
514
530
|
? {}
|
|
515
531
|
: {
|
|
516
|
-
baseVersions: readBaseVersions(siteDir),
|
|
517
|
-
itemBaseVersions: readItemBaseVersions(siteDir)
|
|
532
|
+
baseVersions: readBaseVersions(siteDir, client.origin),
|
|
533
|
+
itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
|
|
518
534
|
}),
|
|
519
535
|
...(assetRewrite ? { assetRewrite } : {}),
|
|
520
536
|
...(assetIds ? { assetIds } : {})
|
package/src/commands/refresh.js
CHANGED
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
headProvenance
|
|
47
47
|
} from '../utils/git.js'
|
|
48
48
|
import { probeUnpushed } from '../backend/site-sync.js'
|
|
49
|
+
import { resolveBackendOrigin } from '../backend/client.js'
|
|
50
|
+
import { readBackendState } from '@uniweb/build/uwx'
|
|
49
51
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
50
52
|
|
|
51
53
|
const c = {
|
|
@@ -65,28 +67,16 @@ const say = {
|
|
|
65
67
|
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const name = names.find((n) => a === n || a.startsWith(`${n}=`))
|
|
77
|
-
if (!name) continue
|
|
78
|
-
out.push(a)
|
|
79
|
-
if (a === name && args[i + 1] && !args[i + 1].startsWith('-'))
|
|
80
|
-
out.push(args[++i])
|
|
81
|
-
}
|
|
82
|
-
return out
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// A site is backend-synced once it has an identity to pull by.
|
|
86
|
-
function siteContentUuid(siteDir) {
|
|
70
|
+
// A site is backend-synced once it has an identity to pull by — on THIS backend,
|
|
71
|
+
// from sync.json. It read `site.yml::$uuid`, so after step 4 refresh treated every
|
|
72
|
+
// project as unsynced and skipped the backend half of its check.
|
|
73
|
+
//
|
|
74
|
+
// ⛔ `backend` is the one the delegated pull will use: the backend the user is logged in
|
|
75
|
+
// to. (For part of 2026-09-21, `refresh --backend X` checked one backend's sync state and
|
|
76
|
+
// pulled from another; the flag is gone from the backend verbs since.)
|
|
77
|
+
function siteContentUuid(siteDir, backend) {
|
|
87
78
|
try {
|
|
88
|
-
|
|
89
|
-
return typeof y?.$uuid === 'string' ? y.$uuid : null
|
|
79
|
+
return readBackendState(siteDir, backend).site?.uuid || null
|
|
90
80
|
} catch {
|
|
91
81
|
return null
|
|
92
82
|
}
|
|
@@ -155,20 +145,21 @@ export async function refresh(args = [], deps = {}) {
|
|
|
155
145
|
}
|
|
156
146
|
|
|
157
147
|
// ── 2. the backend ────────────────────────────────────────────────────────
|
|
148
|
+
// The backend the delegated pull talks to — the one you are logged in to
|
|
149
|
+
// (resolveBackendOrigin). Everything below asks about THAT one.
|
|
150
|
+
const backend = resolveBackendOrigin()
|
|
158
151
|
if (skipBackend) {
|
|
159
152
|
skipped.push('backend (--no-backend)')
|
|
160
|
-
} else if (!siteContentUuid(siteDir)) {
|
|
153
|
+
} else if (!siteContentUuid(siteDir, backend)) {
|
|
161
154
|
skipped.push('backend (this site has never been synced)')
|
|
162
155
|
} else {
|
|
163
156
|
say.info("Merging the backend's content…")
|
|
164
157
|
const pull = deps.pull || (await import('./pull.js')).pull
|
|
165
158
|
// `--merge` rather than a plain pull: an author editing a different part of the
|
|
166
159
|
// same section is not a conflict, and should not be presented as one.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
])
|
|
171
|
-
const res = await pull(['--merge', ...passthrough])
|
|
160
|
+
// Nothing of refresh's own argv is forwarded: the pull goes to the backend you are
|
|
161
|
+
// logged in to, as refresh does, and authenticates the same way.
|
|
162
|
+
const res = await pull(['--merge'])
|
|
172
163
|
conflicts = res?.merge?.conflicted?.length ?? 0
|
|
173
164
|
if (res?.exitCode && !conflicts) {
|
|
174
165
|
// Failed for a reason other than conflicts — say so plainly rather than
|
|
@@ -192,9 +183,11 @@ export async function refresh(args = [], deps = {}) {
|
|
|
192
183
|
|
|
193
184
|
// What is still yours to send. The point of a milestone check is knowing both
|
|
194
185
|
// directions, not just that you took what was waiting.
|
|
195
|
-
if (!skipBackend && siteContentUuid(siteDir)) {
|
|
186
|
+
if (!skipBackend && siteContentUuid(siteDir, backend)) {
|
|
196
187
|
try {
|
|
197
|
-
|
|
188
|
+
// Whose asset ids the comparison reads — see status.js. Offline, so the
|
|
189
|
+
// origin is resolved rather than taken from a client.
|
|
190
|
+
const probe = await probeUnpushed(siteDir, { backend })
|
|
198
191
|
if (probe.changed)
|
|
199
192
|
say.dim(
|
|
200
193
|
`Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`
|