uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb push — push a site to the backend over its two directional lanes:
|
|
3
|
+
* - content lane → `@uniweb/site-content` (the static half: pages, sections,
|
|
4
|
+
* layout, theme, foundation ref, extensions, collection decls);
|
|
5
|
+
* - folder lane → one `@uniweb/folder` + the collection-record entities it
|
|
6
|
+
* references (the dynamic half; the `$ref` closure rides together).
|
|
7
|
+
*
|
|
8
|
+
* Each entity is an entity-content document (`$id` + `$schema` + 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
|
|
13
|
+
* UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
|
|
14
|
+
* the backend owns the site's `@uniweb/folder`, so the framework never holds a
|
|
15
|
+
* folder uuid. Records still round-trip their own `$uuid`
|
|
16
|
+
* (back-filled into their source files). site-content items carry a per-item `$uuid`
|
|
17
|
+
* too, stamped at emit from `sync.json` rather than from author files — without
|
|
18
|
+
* it the backend reads every record as new and recreates every page and section row.
|
|
19
|
+
* Push-only, and gated on the backend's per-entity `version`
|
|
20
|
+
* (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
|
|
21
|
+
*
|
|
22
|
+
* Order: content first (CREATE or UPDATE — the site must exist before its folder),
|
|
23
|
+
* then the folder, keyed by the site's uuid. On a brand-new site the backend creates
|
|
24
|
+
* the folder on its first folder push for that uuid.
|
|
25
|
+
*
|
|
26
|
+
* `uniweb login && uniweb push`. Run from a site, or a workspace with one site.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* uniweb push Build, push both lanes, back-fill $uuid
|
|
30
|
+
* uniweb push --org @org Work in @org for this push, instead of the
|
|
31
|
+
* uniweb push --personal workspace chosen at `uniweb login` (or your
|
|
32
|
+
* personal workspace). A site this push creates
|
|
33
|
+
* is created — owned — there; a site the backend
|
|
34
|
+
* keeps in another workspace stops the push.
|
|
35
|
+
* uniweb push --dry-run Report what would be pushed; submit nothing
|
|
36
|
+
* uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
|
|
37
|
+
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
38
|
+
* uniweb push --all Send every record (bypass the changed-only cache)
|
|
39
|
+
* uniweb push --force Overwrite upstream changes (drop the staleness gate)
|
|
40
|
+
* uniweb push --no-release Send content against the foundation already
|
|
41
|
+
* released; release nothing
|
|
42
|
+
* uniweb push --bump Release above a newer registered version
|
|
43
|
+
*
|
|
44
|
+
* Pushes are GATED by default: each entity carries the backend `version` this clone
|
|
45
|
+
* last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
|
|
46
|
+
* atomically — before any write — if its stored version has moved. That prevents a
|
|
47
|
+
* developer who hasn't pulled from silently destroying an app author's edits (the
|
|
48
|
+
* backend's reconcile deletes items absent from the package, so an author's NEW page
|
|
49
|
+
* would be hard-deleted). `--force` omits the token and restores last-push-wins.
|
|
50
|
+
*
|
|
51
|
+
* Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
|
|
52
|
+
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
53
|
+
* No `--server` or `--token`: switching and signing in are `uniweb login`.
|
|
54
|
+
*
|
|
55
|
+
* The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
|
|
56
|
+
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
|
|
57
|
+
* (the composite path) reuses the exact same logic. This command owns flag parsing,
|
|
58
|
+
* the emit, and the `-o`/`--dry-run` preview.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
62
|
+
import { join, resolve } from 'node:path'
|
|
63
|
+
import yaml from 'js-yaml'
|
|
64
|
+
import { emitSyncPackages, readBackendState } from '@uniweb/build/uwx'
|
|
65
|
+
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
66
|
+
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
67
|
+
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
68
|
+
import { BackendClient, BackendUnreachableError } from '../backend/client.js'
|
|
69
|
+
import { resolveSiteDir } from './deploy.js'
|
|
70
|
+
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
71
|
+
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
72
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
73
|
+
import {
|
|
74
|
+
syncedElsewhere,
|
|
75
|
+
describeSyncedElsewhere
|
|
76
|
+
} from '../utils/site-identity.js'
|
|
77
|
+
import { confirm, isNonInteractive } from '../utils/interactive.js'
|
|
78
|
+
import { announceServices, foundationSupports } from '../backend/service-request.js'
|
|
79
|
+
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
80
|
+
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
81
|
+
import {
|
|
82
|
+
makeModelResolver,
|
|
83
|
+
readSyncCache,
|
|
84
|
+
readBaseVersions,
|
|
85
|
+
readItemBaseVersions,
|
|
86
|
+
readItemUuids,
|
|
87
|
+
readFolderItemUuids,
|
|
88
|
+
readQueryUuids,
|
|
89
|
+
readRecordItemUuids,
|
|
90
|
+
deploymentFields,
|
|
91
|
+
ensureRegisteredFoundation,
|
|
92
|
+
recoverUnbankedIdentity,
|
|
93
|
+
ensureItemUuids,
|
|
94
|
+
refuseUnsendableRecords,
|
|
95
|
+
ensureSiteExists,
|
|
96
|
+
clearRemoteSyncStateIfUnbound,
|
|
97
|
+
dropSiteBoundValues,
|
|
98
|
+
pushSyncPackages
|
|
99
|
+
} from '../backend/site-sync.js'
|
|
100
|
+
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
101
|
+
|
|
102
|
+
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
103
|
+
// helpers from this module — their canonical home is now ../backend/site-sync.js.
|
|
104
|
+
export {
|
|
105
|
+
extractMintedSiteUuid,
|
|
106
|
+
makeModelResolver
|
|
107
|
+
} from '../backend/site-sync.js'
|
|
108
|
+
|
|
109
|
+
const colors = {
|
|
110
|
+
reset: '\x1b[0m',
|
|
111
|
+
bright: '\x1b[1m',
|
|
112
|
+
dim: '\x1b[2m',
|
|
113
|
+
red: '\x1b[31m',
|
|
114
|
+
green: '\x1b[32m',
|
|
115
|
+
yellow: '\x1b[33m',
|
|
116
|
+
blue: '\x1b[36m'
|
|
117
|
+
}
|
|
118
|
+
const log = console.log
|
|
119
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
120
|
+
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
121
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
122
|
+
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
123
|
+
const warn = (m) => log(`${colors.yellow}\u26a0${colors.reset} ${m}`)
|
|
124
|
+
|
|
125
|
+
function flagValue(args, name) {
|
|
126
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
127
|
+
if (eq) return eq.slice(name.length + 1)
|
|
128
|
+
const i = args.indexOf(name)
|
|
129
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
|
|
130
|
+
return args[i + 1]
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function push(args = [], deps = {}) {
|
|
135
|
+
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
136
|
+
// default — including for --backend, where the default can be production.
|
|
137
|
+
//
|
|
138
|
+
// `sync` validates the UNION of its two halves' flags and forwards raw argv, so a
|
|
139
|
+
// legal `uniweb sync --no-git` would be rejected here. It passes `skipFlagCheck`.
|
|
140
|
+
if (!deps.skipFlagCheck) {
|
|
141
|
+
const bad = checkFlags('push', args)
|
|
142
|
+
if (bad) {
|
|
143
|
+
error(bad.message)
|
|
144
|
+
return { exitCode: 2 }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const dryRun = args.includes('--dry-run')
|
|
148
|
+
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
149
|
+
const foundationDir = flagValue(args, '--foundation')
|
|
150
|
+
const sendAll = args.includes('--all') // bypass the send-only-changed cache
|
|
151
|
+
// --force drops the optimistic-concurrency precondition, making the push
|
|
152
|
+
// unconditional (the backend then falls back to its `collision` policy). It is
|
|
153
|
+
// deliberately NOT "send collision=force": when a base_version is present the
|
|
154
|
+
// backend consults it and never looks at `collision`, so forcing has to mean
|
|
155
|
+
// OMITTING the token — the HTTP If-Match idiom.
|
|
156
|
+
const force = args.includes('--force')
|
|
157
|
+
|
|
158
|
+
const siteDir = await resolveSiteDir(args, 'push')
|
|
159
|
+
|
|
160
|
+
// ⭐ A GATE, before anything is sent: the backend checks these records against the
|
|
161
|
+
// schemas this push registers, so one that does not conform would be refused on
|
|
162
|
+
// arrival or lose a value on the way. See utils/conformance.js.
|
|
163
|
+
if (await refuseIfContentDoesNotConform(siteDir, { args })) return { exitCode: 1 }
|
|
164
|
+
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
165
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
|
|
166
|
+
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
167
|
+
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
168
|
+
// that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
|
|
169
|
+
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
170
|
+
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
171
|
+
// be reached: *"We do not allow any communication with backend if the user is not
|
|
172
|
+
// logged into a backend."*
|
|
173
|
+
// One front door. The bearer is resolved lazily on first need (a non-local Model
|
|
174
|
+
// read during the build, or the submit). Offline emit (--dry-run / -o) is fully
|
|
175
|
+
// offline: it never submits, and its Model resolver never reads from the backend
|
|
176
|
+
// (the `offline` flag below), so it never authenticates — even when a collection
|
|
177
|
+
// references a Model the local foundation doesn't define.
|
|
178
|
+
const client = new BackendClient({
|
|
179
|
+
args,
|
|
180
|
+
command: 'Syncing'
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ⛔ A COPY OF ANOTHER PROJECT — before anything is sent. A plain directory copy
|
|
184
|
+
// carries sync.json, so it holds the ORIGINAL's site on this backend and its push
|
|
185
|
+
// would update that site (see utils/site-copies.js for why the signal is exact).
|
|
186
|
+
// Refused, not warned: a warning printed while the push goes ahead is printed over
|
|
187
|
+
// the damage. Runs after the client so it checks the RESOLVED origin.
|
|
188
|
+
//
|
|
189
|
+
// ⚠️ NOT for `-o`, which is a LOCAL EMIT and reaches no backend at all — there is no
|
|
190
|
+
// site for it to update, and refusing would break an operation this command keeps
|
|
191
|
+
// offline (the `!output && !dryRun` guards below are the same rule).
|
|
192
|
+
//
|
|
193
|
+
// `--dry-run` IS checked, and the asymmetry is the point: a dry run previews a real
|
|
194
|
+
// push, so when that push would be refused, saying so is the honest preview.
|
|
195
|
+
//
|
|
196
|
+
// *(This spot held the backend SCOPE CHECK, `assertSiteBackendScope`, until
|
|
197
|
+
// 2026-09-20 — deleted when identity became per-backend and a foreign backend's
|
|
198
|
+
// uuids stopped being reachable at all.)*
|
|
199
|
+
if (!output) {
|
|
200
|
+
const copies = findSiteCopies(siteDir, client.origin)
|
|
201
|
+
if (copies.length) {
|
|
202
|
+
const said = describeSiteCopies(copies, client.origin, 'push')
|
|
203
|
+
error(said.headline)
|
|
204
|
+
for (const line of said.lines) note(line)
|
|
205
|
+
return { exitCode: 1 }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ⚠️ Logged in to a backend where this project has no site, while it has one
|
|
210
|
+
// elsewhere: following the login CREATES a second site. Say so before the owner
|
|
211
|
+
// question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
|
|
212
|
+
// created) and not when UNIWEB_SERVER chose the backend — that is a decision.
|
|
213
|
+
if (!output && !process.env.UNIWEB_SERVER) {
|
|
214
|
+
const known = syncedElsewhere(siteDir, client.origin)
|
|
215
|
+
if (known) {
|
|
216
|
+
const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'push')
|
|
217
|
+
info(headline)
|
|
218
|
+
for (const line of rest) note(line)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ⭐ THE WORKSPACE THIS PUSH WORKS IN — the one chosen with the login, unless this
|
|
223
|
+
// command names another (`workspace.js`). Resolved before any lane runs: a site this
|
|
224
|
+
// push creates is created in it, and a site the backend keeps elsewhere is refused.
|
|
225
|
+
const ws = await resolveWorkspace({ client, args, offline: !!output || dryRun })
|
|
226
|
+
if (ws.refused) {
|
|
227
|
+
error('This push works in one workspace, and none is chosen.')
|
|
228
|
+
note(ws.reason)
|
|
229
|
+
return { exitCode: 2 }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Bring the foundation along — BEFORE any asset upload, because an upload is
|
|
233
|
+
// chargeable and a push that aborts after one has spent the user's money for
|
|
234
|
+
// nothing.
|
|
235
|
+
//
|
|
236
|
+
// ⭐ Why push and not just publish. [Diego, 2026-08-19] — *"A published site can
|
|
237
|
+
// only reference a registered foundation … In fact, not even a push can, because
|
|
238
|
+
// we can't preview the site in the frontend in that case."* Push is the
|
|
239
|
+
// collaboration verb: a teammate opens the site in the visual app right after,
|
|
240
|
+
// and the app can only render against foundation code the backend can serve. So
|
|
241
|
+
// storing an unregistered ref does not merely defer a problem to publish — it
|
|
242
|
+
// hands the teammate a site that cannot render, which is where they meet it.
|
|
243
|
+
//
|
|
244
|
+
// `fnd.ref` is the pinned `@scope/name@version`, stamped onto the wire below.
|
|
245
|
+
// That also makes push and publish agree about the document they emit; until now
|
|
246
|
+
// push sent the authored string and publish sent the pinned ref, so the two saw
|
|
247
|
+
// each other's pushes as changes.
|
|
248
|
+
const siteYml = (() => {
|
|
249
|
+
try {
|
|
250
|
+
return yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8')) || {}
|
|
251
|
+
} catch {
|
|
252
|
+
return {}
|
|
253
|
+
}
|
|
254
|
+
})()
|
|
255
|
+
const say = {
|
|
256
|
+
ok: success,
|
|
257
|
+
info,
|
|
258
|
+
warn,
|
|
259
|
+
err: error,
|
|
260
|
+
dim: note
|
|
261
|
+
}
|
|
262
|
+
let fnd = { ref: null }
|
|
263
|
+
try {
|
|
264
|
+
fnd = await bringFoundationAlong({
|
|
265
|
+
client,
|
|
266
|
+
siteDir,
|
|
267
|
+
siteYml,
|
|
268
|
+
args,
|
|
269
|
+
say,
|
|
270
|
+
confirm,
|
|
271
|
+
cliBin: process.argv[1],
|
|
272
|
+
// An offline emit reports what it WOULD do and touches no network; the ref
|
|
273
|
+
// still comes back (read from the foundation's package.json) so the preview
|
|
274
|
+
// matches what a real push sends — EXCEPT for a foundation never yet
|
|
275
|
+
// registered, which has no scope to form a ref from until the first release
|
|
276
|
+
// writes one. See the dry-run branch in foundation-bring-along.js.
|
|
277
|
+
dryRun: !!output || dryRun,
|
|
278
|
+
verb: 'push'
|
|
279
|
+
})
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// A backend that did not answer is said as that — not as a release that failed.
|
|
282
|
+
if (err instanceof BackendUnreachableError) {
|
|
283
|
+
error(err.message)
|
|
284
|
+
note('Nothing was released or sent. Check that the backend is running, then re-run `uniweb push`.')
|
|
285
|
+
return { exitCode: 1 }
|
|
286
|
+
}
|
|
287
|
+
// A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
|
|
288
|
+
error(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
|
|
289
|
+
for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb push`.']) note(line)
|
|
290
|
+
return { exitCode: 1 }
|
|
291
|
+
}
|
|
292
|
+
if (!fnd.proceed) return { exitCode: 1 }
|
|
293
|
+
|
|
294
|
+
// ⭐ Every site request names the workspace; a site outside it stops the push
|
|
295
|
+
// (`WorkspaceMismatchError`). A site this push creates is created in it — said first.
|
|
296
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
297
|
+
if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
|
|
298
|
+
note(`This push creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Build BOTH directional packages (the producer side). Each carries its own
|
|
302
|
+
// `index` — the per-entity source-file map for back-fill, correlated by submission
|
|
303
|
+
// position. Non-local Models are fetched from the registry on demand. `priorHashes`
|
|
304
|
+
// (the .uniweb push-cache) drives "send only changed" across both lanes; --all bypasses.
|
|
305
|
+
// ⛔ DROP STALE REMOTE STATE FIRST — this must precede the hash read below, and it
|
|
306
|
+
// did not until 2026-08-19.
|
|
307
|
+
//
|
|
308
|
+
// The guard itself is right and its docblock names this exact outcome: on a clone
|
|
309
|
+
// whose `$uuid` was cleared to re-publish as a new site — WHICH IS OUR OWN
|
|
310
|
+
// DOCUMENTED RECOVERY — send-only-changed would "skip every entity whose content
|
|
311
|
+
// had not changed since the OLD site's last push, so the NEW site would come up
|
|
312
|
+
// missing exactly the content that did not change." It was defeated twice over:
|
|
313
|
+
//
|
|
314
|
+
// · it ran AFTER `readSyncCache`, so the emit still diffed against the old
|
|
315
|
+
// site's hashes even though the file on disk had just been emptied;
|
|
316
|
+
// · it sat inside `if (mediaRefs.length)`, so a site with no local images never
|
|
317
|
+
// reached it at all.
|
|
318
|
+
//
|
|
319
|
+
// Measured 2026-08-19 against a live uniwebd: clearing `$uuid` and pushing created
|
|
320
|
+
// the new site and pulled back **0 pages, 0 sections**. Successful exit, empty site,
|
|
321
|
+
// nothing to indicate it — the failure the guard was written to prevent.
|
|
322
|
+
//
|
|
323
|
+
// Still skipped for an offline emit: `-o` / `--dry-run` must not mutate project state.
|
|
324
|
+
if (!output && !dryRun) {
|
|
325
|
+
const dropped = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
|
|
326
|
+
if (dropped.length) {
|
|
327
|
+
note(
|
|
328
|
+
`Cleared stale sync state from a previous site (${dropped.join(', ')}).`
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
const stale = dropSiteBoundValues(siteDir, client.origin)
|
|
332
|
+
if (stale.length) {
|
|
333
|
+
note(`Dropped the previous site's ${stale.join(' and ')} from site.yml.`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const priorHashes = readSyncCache(siteDir, client.origin)
|
|
337
|
+
// Per-item identity, without which the backend reads every record as new and
|
|
338
|
+
// recreates every page and section row.
|
|
339
|
+
//
|
|
340
|
+
// An offline preview (`-o` / `--dry-run`) still stamps from the CACHE — that is a
|
|
341
|
+
// local file read, so it stays offline, and it keeps the emitted `.uwx` faithful
|
|
342
|
+
// to what a real push would send. Only the network RECOVERY is skipped, so a
|
|
343
|
+
// preview never reaches the backend. (Emitting `{}` here instead would make the
|
|
344
|
+
// preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
|
|
345
|
+
// Local media rides the SAME asset lane `publish` uses, and it rides it FIRST.
|
|
346
|
+
//
|
|
347
|
+
// Push is the collaboration verb: a teammate opens the site in the visual app
|
|
348
|
+
// right after it. Content that still points at `/images/hero.png` — bytes the
|
|
349
|
+
// backend never received — shows them a broken image, which is precisely what
|
|
350
|
+
// push exists to avoid. `publish` uploaded and rewrote; push dropped
|
|
351
|
+
// `localAssets` on the floor.
|
|
352
|
+
//
|
|
353
|
+
// The upload runs before `ensureItemUuids`, and the reason once recorded here —
|
|
354
|
+
// "BEFORE `ensureItemUuids`, which mints uuids on the backend, so a refusal
|
|
355
|
+
// leaves nothing minted" — is FALSE. `ensureItemUuids` mints nothing: it reads a
|
|
356
|
+
// local map, and if that is empty it reads this backend's site uuid from sync.json
|
|
357
|
+
// and returns immediately when there is none (the first-push case). Its only backend call is
|
|
358
|
+
// a GET (`pullSiteContent`); its only write is a local file. So this ordering is
|
|
359
|
+
// not protecting what that comment claimed, and is under review — an upload is a
|
|
360
|
+
// durable, metered write, which makes uploading first the thing that leaves bytes
|
|
361
|
+
// behind when a later step fails.
|
|
362
|
+
//
|
|
363
|
+
// The probe emit runs WITHOUT `priorHashes`, so it surfaces every local ref
|
|
364
|
+
// rather than only the changed ones. That is not waste — the lane is
|
|
365
|
+
// content-addressed with a `present` skip-list, so unchanged bytes are a no-op
|
|
366
|
+
// PUT. It is also what makes the storage rule fall out of the mechanism instead
|
|
367
|
+
// of needing a special case: a content-only push adds no new stored content, so
|
|
368
|
+
// a quota has nothing to refuse. Only a push that genuinely ADDS bytes can be
|
|
369
|
+
// blocked.
|
|
370
|
+
//
|
|
371
|
+
// Two claims live here and only ONE is a guarantee, per the backend's asset
|
|
372
|
+
// accounting model:
|
|
373
|
+
// - "moves zero bytes" is the COMMON CASE, not a property. `present` is an
|
|
374
|
+
// optimization: a failed presence probe degrades to all-absent, so unchanged
|
|
375
|
+
// bytes may be re-PUT. It is never a false positive, so skipping stays safe.
|
|
376
|
+
// - "is never refused for storage" IS a property, because metering is
|
|
377
|
+
// IDEMPOTENT per (workspace, asset) and recorded at PLAN time. Presenting an
|
|
378
|
+
// asset already on this workspace's books charges nothing, so a push that
|
|
379
|
+
// changes only content meters nothing and cannot be refused.
|
|
380
|
+
//
|
|
381
|
+
// ⚠️ "unchanged bytes skip the transfer" is TRUE. "…and are therefore FREE" is
|
|
382
|
+
// NOT — do not write it into a message, a summary, or a comment. Every upload is
|
|
383
|
+
// chargeable; deduplication is the backend's storage optimization, not the user's
|
|
384
|
+
// discount, so an asset another workspace already uploaded comes back
|
|
385
|
+
// `present: true`, never PUTs, and is STILL charged to this one. What is free is
|
|
386
|
+
// copying URLs around (duplicating a site, a snapshot, a backup).
|
|
387
|
+
//
|
|
388
|
+
// Consequently `needed_bytes` = "assets not yet on this workspace's books", which
|
|
389
|
+
// can be NON-ZERO with zero transfers — so a push can print no `↑` line at all and
|
|
390
|
+
// still be refused. A refusal message must talk about assets new to the workspace,
|
|
391
|
+
// never "the files being uploaded", and must not size itself from what moved.
|
|
392
|
+
//
|
|
393
|
+
// Two framings were proposed and WITHDRAWN in-channel; do not reintroduce either:
|
|
394
|
+
// "charged on distinct stored content", and "the quota fails open on a
|
|
395
|
+
// presence-probe failure" (retired — `needed_bytes` reads the ledger, not the
|
|
396
|
+
// probe, so the property no longer depends on that error path behaving).
|
|
397
|
+
//
|
|
398
|
+
// It also has to be this emit that carries `assetRewrite` below: the push cache
|
|
399
|
+
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
400
|
+
// rewrite too, or every entity reads as changed forever.
|
|
401
|
+
// ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
|
|
402
|
+
// is destructive — a directory emptied by accident, or kept with only a
|
|
403
|
+
// placeholder — so the count is reported and confirmed before anything is sent.
|
|
404
|
+
// The format stays honest; the asking happens here.
|
|
405
|
+
if (!dryRun) {
|
|
406
|
+
const guard = await guardEmptyRecords({ siteDir, backend: client.origin, args, warn, note })
|
|
407
|
+
if (!guard.ok) return { exitCode: 1 }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let assetRewrite = null
|
|
411
|
+
let assetIds = null
|
|
412
|
+
if (!output && !dryRun) {
|
|
413
|
+
let mediaRefs = []
|
|
414
|
+
let refusals = []
|
|
415
|
+
try {
|
|
416
|
+
const probe = await emitSyncPackages(siteDir, {
|
|
417
|
+
backend: client.origin,
|
|
418
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
419
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
420
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
421
|
+
resolveModel: makeModelResolver({ client, offline: false, siteDir })
|
|
422
|
+
})
|
|
423
|
+
// Site-root media, and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`).
|
|
424
|
+
mediaRefs = [...(probe.localAssets || []), ...(probe.localFiles || [])]
|
|
425
|
+
refusals = probe.refusals || []
|
|
426
|
+
} catch (err) {
|
|
427
|
+
error(`Could not scan the site for local media: ${err.message}`)
|
|
428
|
+
return { exitCode: 2 }
|
|
429
|
+
}
|
|
430
|
+
// A record the backend would refuse stops the push HERE — before the site is
|
|
431
|
+
// created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
|
|
432
|
+
if (refuseUnsendableRecords(refusals, { error, note })) return { exitCode: 1 }
|
|
433
|
+
if (mediaRefs.length) {
|
|
434
|
+
// The site has to exist before its bytes do — an upload with no owning
|
|
435
|
+
// entity is charged and cannot be freed, because freeing means deleting the
|
|
436
|
+
// owner. A no-op once `$uuid` is set, so only a never-synced site pays.
|
|
437
|
+
// ⛔ The PINNED ref, not site.yml's. The create is a THIRD writer of this
|
|
438
|
+
// value — the emit below stamps it via `injectInfo`, publish passes it here,
|
|
439
|
+
// and push did neither until 2026-08-19: it sent the authored alias (`src`),
|
|
440
|
+
// which names a foundation no deployment can resolve. A site created that way
|
|
441
|
+
// keeps the bad ref, and the backend's create guard now refuses it outright
|
|
442
|
+
// (channel backend↔framework, their measurement).
|
|
443
|
+
//
|
|
444
|
+
// ⭐ Same shape as the send-only-changed defect fixed this morning, and missed
|
|
445
|
+
// for the same reason: the rule was applied at the writers under discussion and
|
|
446
|
+
// a third caller of the same value was never enumerated.
|
|
447
|
+
const site = await ensureSiteExists({
|
|
448
|
+
client,
|
|
449
|
+
siteDir,
|
|
450
|
+
note,
|
|
451
|
+
...(fnd.ref ? { foundation: fnd.ref } : {})
|
|
452
|
+
})
|
|
453
|
+
if (!site.uuid) {
|
|
454
|
+
error(`Could not create the site on the backend: ${site.reason}`)
|
|
455
|
+
note('Nothing was uploaded and nothing was charged.')
|
|
456
|
+
return { exitCode: 1 }
|
|
457
|
+
}
|
|
458
|
+
info('Uploading media…')
|
|
459
|
+
try {
|
|
460
|
+
const { map, ids, failed } = await uploadSiteMedia(
|
|
461
|
+
client,
|
|
462
|
+
siteDir,
|
|
463
|
+
mediaRefs,
|
|
464
|
+
{
|
|
465
|
+
siteUuid: site.uuid,
|
|
466
|
+
onProgress: (m) => note(` ${m}`),
|
|
467
|
+
warn: (m) => note(`! ${m}`)
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
// Bytes that did not land must not be pushed around: the content would go
|
|
471
|
+
// up still naming the local path, so the teammate sees the broken image
|
|
472
|
+
// this whole change exists to prevent, and the only trace is a warning. A
|
|
473
|
+
// missing FILE is a different thing — already broken before us, warned by
|
|
474
|
+
// the uploader, and not worth blocking a push over.
|
|
475
|
+
if (failed.length) {
|
|
476
|
+
error(
|
|
477
|
+
`${failed.length} asset(s) failed to upload — nothing was pushed.`
|
|
478
|
+
)
|
|
479
|
+
for (const f of failed) note(` ${f.path} (HTTP ${f.status})`)
|
|
480
|
+
return { exitCode: 1 }
|
|
481
|
+
}
|
|
482
|
+
if (Object.keys(map).length) assetRewrite = map
|
|
483
|
+
if (Object.keys(ids).length) assetIds = ids
|
|
484
|
+
note(
|
|
485
|
+
`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`
|
|
486
|
+
)
|
|
487
|
+
// Record identity in the COMMITTED map. Merge, never replace: this push
|
|
488
|
+
// carries only the refs its content touched.
|
|
489
|
+
const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
|
|
490
|
+
if (rec.written) {
|
|
491
|
+
note(
|
|
492
|
+
`${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
} catch (err) {
|
|
496
|
+
// Typed plan refusals get their own account. Note the storage one must not
|
|
497
|
+
// be phrased from what moved — see describeAssetRefusal's rule 1; a push can
|
|
498
|
+
// print no `↑` line at all and still be refused.
|
|
499
|
+
const refusal = describeAssetRefusal(err)
|
|
500
|
+
if (refusal) {
|
|
501
|
+
error(refusal.headline)
|
|
502
|
+
for (const line of refusal.notes) note(line)
|
|
503
|
+
} else {
|
|
504
|
+
error(`Media upload failed: ${err.message}`)
|
|
505
|
+
}
|
|
506
|
+
return { exitCode: 1 }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const itemUuids =
|
|
512
|
+
output || dryRun
|
|
513
|
+
? readItemUuids(siteDir, client.origin)
|
|
514
|
+
: await ensureItemUuids({ client, siteDir, note })
|
|
515
|
+
const fields = await deploymentFields({
|
|
516
|
+
client,
|
|
517
|
+
siteDir,
|
|
518
|
+
offline: Boolean(output) || dryRun
|
|
519
|
+
})
|
|
520
|
+
// A foundation named by catalog ref — a clone's: its kept registered version types a query named
|
|
521
|
+
// for a data key (`ensureRegisteredFoundation`). Offline, only what is already kept.
|
|
522
|
+
if (!output && !dryRun) {
|
|
523
|
+
await ensureRegisteredFoundation({
|
|
524
|
+
client,
|
|
525
|
+
siteDir,
|
|
526
|
+
siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
|
|
527
|
+
ref: siteYml?.foundation
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
// ⭐ THE SERVICES `site.yml` ASKS FOR are stated by the emit — the file's, and off for
|
|
531
|
+
// each held one it no longer lists (`statedServices`) — and the backend decides per
|
|
532
|
+
// service. Said here: what the file asks that will not be sent as written.
|
|
533
|
+
announceServices({ siteYml, say: { warn }, supports: await foundationSupports(siteDir, siteYml) })
|
|
534
|
+
const emitOptions = {
|
|
535
|
+
backend: client.origin,
|
|
536
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
537
|
+
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
538
|
+
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
539
|
+
// name, because a declaration has no file for a path-keyed map to hold.
|
|
540
|
+
queryUuids: readQueryUuids(siteDir, client.origin),
|
|
541
|
+
// The keys this deployment's Sections take — see deploymentFields.
|
|
542
|
+
...fields,
|
|
543
|
+
// Identity for the records' list items — see readRecordItemUuids.
|
|
544
|
+
recordItemUuids: readRecordItemUuids(siteDir, client.origin),
|
|
545
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
546
|
+
resolveModel: makeModelResolver({
|
|
547
|
+
client,
|
|
548
|
+
offline: Boolean(output) || dryRun,
|
|
549
|
+
siteDir
|
|
550
|
+
}),
|
|
551
|
+
priorHashes,
|
|
552
|
+
sendAll,
|
|
553
|
+
itemUuids,
|
|
554
|
+
// The PINNED foundation ref from the bring-along above, stamped over the
|
|
555
|
+
// authored `site.yml` string. Delivery is version-pinned end to end, so an
|
|
556
|
+
// unpinned local name on the wire names code no host can serve. Absent when
|
|
557
|
+
// the site already references a registry ref or URL — then site.yml's own
|
|
558
|
+
// value rides verbatim.
|
|
559
|
+
...(fnd.ref ? { injectInfo: { foundation: fnd.ref } } : {}),
|
|
560
|
+
// Both grains are dropped together by --force: one flag, one meaning,
|
|
561
|
+
// no partial-force mode.
|
|
562
|
+
...(force
|
|
563
|
+
? {}
|
|
564
|
+
: {
|
|
565
|
+
baseVersions: readBaseVersions(siteDir, client.origin),
|
|
566
|
+
itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
|
|
567
|
+
}),
|
|
568
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
569
|
+
...(assetIds ? { assetIds } : {})
|
|
570
|
+
}
|
|
571
|
+
let pkg
|
|
572
|
+
try {
|
|
573
|
+
pkg = await emitSyncPackages(siteDir, emitOptions)
|
|
574
|
+
// A page, section or record list item the backend holds and this copy has no identity
|
|
575
|
+
// for — a map left partial, or a record pushed before its items were banked — is refused
|
|
576
|
+
// if sent without it. Recover it from the backend, and build the package again with it.
|
|
577
|
+
const again =
|
|
578
|
+
output || dryRun ? null : await recoverUnbankedIdentity({ client, siteDir, pkg, note })
|
|
579
|
+
if (again) pkg = await emitSyncPackages(siteDir, { ...emitOptions, ...again })
|
|
580
|
+
} catch (err) {
|
|
581
|
+
error(`Could not build the sync package: ${err.message}`)
|
|
582
|
+
return { exitCode: 2 }
|
|
583
|
+
}
|
|
584
|
+
const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
|
|
585
|
+
log('')
|
|
586
|
+
for (const w of warnings) note(`! ${w}`)
|
|
587
|
+
// The same stop for the paths the probe above does not run on — `-o` and
|
|
588
|
+
// `--dry-run` say what a push would do, and a push would stop here.
|
|
589
|
+
if (refuseUnsendableRecords(pkg.refusals, { error, note })) return { exitCode: 1 }
|
|
590
|
+
// Warn level, not dim: this is the author choosing entities vs static files.
|
|
591
|
+
reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
|
|
592
|
+
|
|
593
|
+
const totalEntities =
|
|
594
|
+
(siteContent?.entityCount || 0) + (records?.entityCount || 0)
|
|
595
|
+
|
|
596
|
+
// Nothing changed since the last push — the backend is already up to date.
|
|
597
|
+
if (totalEntities === 0) {
|
|
598
|
+
success(
|
|
599
|
+
`Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
|
|
600
|
+
)
|
|
601
|
+
return { exitCode: 0 }
|
|
602
|
+
}
|
|
603
|
+
if (siteContent)
|
|
604
|
+
info(
|
|
605
|
+
`${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`
|
|
606
|
+
)
|
|
607
|
+
if (records) {
|
|
608
|
+
const n = records.entityCount
|
|
609
|
+
info(
|
|
610
|
+
`${colors.bright}records${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${records.models.join(', ')}`
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
if (skipped) note(`${skipped} unchanged, skipped`)
|
|
614
|
+
|
|
615
|
+
// Preview paths — no submit, no auth. Two lanes → up to two files / two routes.
|
|
616
|
+
if (output) {
|
|
617
|
+
const base = output.replace(/\.uwx$/, '')
|
|
618
|
+
if (siteContent)
|
|
619
|
+
writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
|
|
620
|
+
if (records)
|
|
621
|
+
writeFileSync(resolve(`${base}.records.uwx`), records.buffer)
|
|
622
|
+
const lanes = [
|
|
623
|
+
siteContent && 'site-content',
|
|
624
|
+
records && 'records'
|
|
625
|
+
].filter(Boolean)
|
|
626
|
+
success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
|
|
627
|
+
return { exitCode: 0 }
|
|
628
|
+
}
|
|
629
|
+
if (dryRun) {
|
|
630
|
+
if (siteContent) {
|
|
631
|
+
const verb = siteContentUuid ? 'update' : 'create'
|
|
632
|
+
info(
|
|
633
|
+
`Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
if (records) {
|
|
637
|
+
info(
|
|
638
|
+
`Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
return { exitCode: 0 }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
|
|
645
|
+
// cache. Shared with `uniweb publish` via ../backend/site-sync.js.
|
|
646
|
+
const result = await pushSyncPackages({
|
|
647
|
+
client,
|
|
648
|
+
siteDir,
|
|
649
|
+
pkg,
|
|
650
|
+
report: {
|
|
651
|
+
info,
|
|
652
|
+
note,
|
|
653
|
+
error,
|
|
654
|
+
dim: (s) => `${colors.dim}${s}${colors.reset}`
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
if (result.exitCode !== 0) return { exitCode: result.exitCode }
|
|
658
|
+
success(
|
|
659
|
+
`Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
|
|
660
|
+
(result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
|
|
661
|
+
)
|
|
662
|
+
return { exitCode: 0 }
|
|
663
|
+
}
|