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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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
+ }