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,1087 @@
1
+ /**
2
+ * uniweb publish — the smart Uniweb-hosting flagship (shipping-model.md §3).
3
+ *
4
+ * `uniweb login && uniweb publish` is meant to be the most ergonomic command in
5
+ * the tool: run it, and it does the right thing — talks to the backend,
6
+ * understands the project, and makes the site live on Uniweb hosting (synced +
7
+ * dynamically served). It:
8
+ *
9
+ * 1. resolves WHICH site (your location, or the workspace's one site; multiple
10
+ * → prompt);
11
+ * 2. BRINGS THE FOUNDATION ALONG — if the site's local foundation changed
12
+ * since its last release, releases the new version first (or asks); a
13
+ * published registry ref needs nothing (§4, foundation-bring-along.js);
14
+ * 3. SYNCS — builds the site data (link mode), uploads media and the
15
+ * schema-less data files, and pushes content (the same two-lane sync
16
+ * `uniweb push` uses);
17
+ * 4. GOES LIVE — POST /dev/site/publish/{uuid}. When the backend answers 402
18
+ * (the site must be paid for first), it opens the settlement URL the
19
+ * backend hands over, verbatim, and stops; the content is already synced,
20
+ * so the author settles and runs publish again (payment-handoff.js).
21
+ *
22
+ * Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
23
+ * (foundation code → catalog). For a self-contained artifact, see `uniweb export`.
24
+ *
25
+ * Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
26
+ * (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
27
+ * No `--server` or `--token`: switching and signing in are `uniweb login`.
28
+ *
29
+ * Usage:
30
+ * uniweb publish Bring the foundation along, sync, and go live
31
+ * uniweb publish --dry-run Resolve everything; POST nothing
32
+ * uniweb publish --yes Skip confirmations (CI); never block on a prompt
33
+ * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
34
+ * uniweb publish --org @org Work in @org for this publish, instead of the
35
+ * uniweb publish --personal workspace chosen at `uniweb login` (or your
36
+ * personal workspace). A site it creates is created
37
+ * — owned — there; a site in another workspace stops
38
+ * it.
39
+ * uniweb publish --no-save Do not record this publish in deploy.yml
40
+ */
41
+
42
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
43
+ import { readFile } from 'node:fs/promises'
44
+ import { join, dirname } from 'node:path'
45
+ import { execSync } from 'node:child_process'
46
+ import yaml from 'js-yaml'
47
+
48
+ import {
49
+ loadDeployYml,
50
+ resolvePublishTarget,
51
+ recordLastDeploy,
52
+ collectSchemalessData,
53
+ collectSchemalessDataAssets,
54
+ rewriteSchemalessDataAssets
55
+ } from '@uniweb/build/site'
56
+ import { emitSyncPackages } from '@uniweb/build/uwx'
57
+ import {
58
+ bankLanguages,
59
+ reconcile,
60
+ announceServices,
61
+ foundationSupports,
62
+ recordsNotAsked
63
+ } from '../backend/service-request.js'
64
+ import { isSiteRelativeExtensionUrl } from '@uniweb/build'
65
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
66
+
67
+ import {
68
+ BackendClient,
69
+ BackendUnreachableError,
70
+ describeRequestError,
71
+ WorkspaceMismatchError
72
+ } from '../backend/client.js'
73
+ import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
74
+ import { resolveSiteDir } from './deploy.js'
75
+ import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
76
+ import { readFlagValue } from '../utils/args.js'
77
+ import { checkFlags } from '../utils/flag-guard.js'
78
+ import {
79
+ syncedElsewhere,
80
+ describeSyncedElsewhere
81
+ } from '../utils/site-identity.js'
82
+ import { isNonInteractive, confirm } from '../utils/interactive.js'
83
+ import { guardEmptyRecords } from '../utils/records-guard.js'
84
+ import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
85
+ import { headProvenance } from '../utils/git.js'
86
+ import {
87
+ makeModelResolver,
88
+ readSyncCache,
89
+ readBaseVersions,
90
+ readItemBaseVersions,
91
+ ensureItemUuids,
92
+ readFolderItemUuids,
93
+ readRecordItemUuids,
94
+ deploymentFields,
95
+ ensureRegisteredFoundation,
96
+ recoverUnbankedIdentity,
97
+ ensureSiteExists,
98
+ refuseUnsendableRecords,
99
+ clearRemoteSyncStateIfUnbound,
100
+ dropSiteBoundValues,
101
+ pushSyncPackages
102
+ } from '../backend/site-sync.js'
103
+ import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
104
+ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
105
+ import {
106
+ updateBackendMap,
107
+ readBackendState,
108
+ carryServed,
109
+ SYNC_STORE_FILE
110
+ } from '@uniweb/build/uwx'
111
+ import {
112
+ bringFoundationAlong,
113
+ bringExtensionsAlong
114
+ } from '../backend/foundation-bring-along.js'
115
+ import {
116
+ readPaymentRefusal,
117
+ settleRefusal
118
+ } from '../backend/payment-handoff.js'
119
+ import { reportSchemalessQueries } from '../utils/schemaless-report.js'
120
+ import {
121
+ uploadSiteData,
122
+ describeDataRefusal
123
+ } from '../utils/site-data-upload.js'
124
+
125
+ const c = {
126
+ reset: '\x1b[0m',
127
+ bold: '\x1b[1m',
128
+ dim: '\x1b[2m',
129
+ cyan: '\x1b[36m',
130
+ green: '\x1b[32m',
131
+ yellow: '\x1b[33m',
132
+ red: '\x1b[31m'
133
+ }
134
+ const say = {
135
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
136
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
137
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
138
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
139
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
140
+ }
141
+
142
+ function readSiteYml(path) {
143
+ if (!existsSync(path)) return {}
144
+ try {
145
+ const parsed = yaml.load(readFileSync(path, 'utf8'))
146
+ return parsed && typeof parsed === 'object' ? parsed : {}
147
+ } catch {
148
+ return {}
149
+ }
150
+ }
151
+
152
+ // Languages from the BUILT site — the authority after a build: `config.languages` where
153
+ // the site declares them (three accepted shapes: 'en', { value, label }, { code, label }),
154
+ // else its default and each language the build produced (`dist/<locale>/site-content.json`).
155
+ // ⛔ Until 2026-09-26 a site that declares no `languages:` — its languages made by its
156
+ // translation files — went live as `['en']`, though the build had produced the others.
157
+ export function languagesFromContent(siteContent, distDir = null) {
158
+ const langs = siteContent?.config?.languages
159
+ if (Array.isArray(langs) && langs.length > 0) {
160
+ return langs
161
+ .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
162
+ .filter(Boolean)
163
+ }
164
+ const source = siteContent?.config?.defaultLanguage || 'en'
165
+ const built =
166
+ distDir && existsSync(distDir)
167
+ ? readdirSync(distDir, { withFileTypes: true })
168
+ .filter((e) => e.isDirectory() && e.name !== source && existsSync(join(distDir, e.name, 'site-content.json')))
169
+ .map((e) => e.name)
170
+ .sort()
171
+ : []
172
+ return [source, ...built]
173
+ }
174
+
175
+ // Languages from site.yml — used only for the dry-run summary (no build yet).
176
+ function languagesFromSiteYml(siteYml) {
177
+ // Legacy `lang:` still honored between defaultLanguage and the shared
178
+ // `defaultLanguage || languages[0] || 'en'` rule.
179
+ const def =
180
+ siteYml.defaultLanguage || siteYml.lang || resolveDefaultLocale(siteYml)
181
+ const locales = siteYml.i18n?.locales || siteYml.languages
182
+ if (!Array.isArray(locales) || locales.length === 0) return null
183
+ const norm = locales
184
+ .map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
185
+ .filter(Boolean)
186
+ return [def, ...norm.filter((l) => l !== def)]
187
+ }
188
+
189
+ // Persist the deploys memo in deploy.yml (skipped on --no-save / saveDeploys: false).
190
+ /**
191
+ * A one-line, human-readable account of a service request, for a terminal.
192
+ *
193
+ * ⛔ Deliberately lossy — it names what is on and what is off, not a service's
194
+ * opaque `config`. The owner is being told WHICH decision differs so they can go
195
+ * look; reproducing a per-service config blob in a warning would bury that.
196
+ * "nothing" is a real answer and reads better than an empty string.
197
+ */
198
+ /**
199
+ * A language selection, for a terminal.
200
+ *
201
+ * ⛔ An ABSENT selection is not an empty one, and the words have to keep them
202
+ * apart: no `publishLanguages` key means every declared language is publishable,
203
+ * while `[]` means explicitly none. "all of them" and "none" are opposite answers
204
+ * and a bare empty string would read as either.
205
+ */
206
+ function describeLanguages(value) {
207
+ if (value === undefined || value === null) return 'all of them'
208
+ if (!Array.isArray(value) || value.length === 0) return 'none'
209
+ return value.join(', ')
210
+ }
211
+
212
+ /**
213
+ * Languages the site was asked to publish that it did not publish.
214
+ *
215
+ * ⭐ THIS IS NOT COMPUTABLE FROM `site.yml`. The served set is decided where the
216
+ * publish happens — a code naming no declared language is ignored rather than
217
+ * refused, and the site's own declared set is whatever its last push left there,
218
+ * which is not necessarily this file. So the only honest source is what the publish
219
+ * reported back, and until now the CLI wrote that into `deploy.yml` and never
220
+ * looked at it.
221
+ *
222
+ * ⛔ The failure it makes visible is the quiet one: an author believes their site
223
+ * is live in three languages and it is live in two. Nothing errors, the publish
224
+ * succeeds, and the missing locale is indistinguishable from one nobody asked for.
225
+ *
226
+ * Only the missing direction is reported. A site serving MORE than was asked is a
227
+ * different question that nobody has, and inventing a message for it would be
228
+ * machinery for a reason that does not exist.
229
+ *
230
+ * @param {string[]|null} asked - what this publish sent
231
+ * @param {*} served - `locales` from the publish response
232
+ * @returns {string[]} asked-for and not served, in the order asked
233
+ */
234
+ export function unservedLanguages(asked, served) {
235
+ if (!Array.isArray(asked) || !Array.isArray(served)) return []
236
+ const got = new Set(served)
237
+ return asked.filter((l) => !got.has(l))
238
+ }
239
+
240
+ async function persistLastDeploy(siteDir, opts) {
241
+ if (opts.saveDeploys === false) return
242
+ try {
243
+ const result = await recordLastDeploy(siteDir, opts)
244
+ if (result?.created)
245
+ say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
246
+ } catch (err) {
247
+ // The publish itself succeeded — never fail the whole command on a
248
+ // memo-write error. Surface it so the user can fix the file.
249
+ say.dim(`Could not update deploy.yml: ${err.message}`)
250
+ }
251
+ }
252
+
253
+ export async function publish(args = []) {
254
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal
255
+ // scan, and for --backend the silent default can be production.
256
+ const bad = checkFlags('publish', args)
257
+ if (bad) {
258
+ say.err(bad.message)
259
+ return { exitCode: 2 }
260
+ }
261
+ const dryRun = args.includes('--dry-run')
262
+ const noSave = args.includes('--no-save')
263
+ const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
264
+
265
+ const siteDir = await resolveSiteDir(args, 'publish')
266
+
267
+ // ⭐ A GATE, before anything is sent — the backend checks these records against the
268
+ // schemas this publish registers. See utils/conformance.js.
269
+ if (await refuseIfContentDoesNotConform(siteDir, { args, error: say.err, warn: say.warn, dim: say.dim })) {
270
+ return { exitCode: 1 }
271
+ }
272
+ const siteYml = readSiteYml(join(siteDir, 'site.yml'))
273
+ // ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
274
+ // backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
275
+ // override — outranks it, and nothing talks to a backend the user is not logged in to:
276
+ // logged in nowhere, the origin is the default backend and the first request asks for
277
+ // that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
278
+ // ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
279
+ // NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
280
+ // be reached: *"We do not allow any communication with backend if the user is not
281
+ // logged into a backend."*
282
+
283
+ const client = new BackendClient({
284
+ args,
285
+ command: 'Publishing'
286
+ })
287
+
288
+ // ⛔ A COPY OF ANOTHER PROJECT — before the foundation bring-along, the sync, or the
289
+ // go-live. A plain directory copy carries sync.json, so it holds the ORIGINAL's site
290
+ // on this backend and would publish over it (utils/site-copies.js). A publish is the
291
+ // longest of these flows and the most expensive to unwind, so it is the one that
292
+ // most benefits from failing at the first step rather than at the fifth.
293
+ // *(The backend scope check that stood here went on 2026-09-20 — see push.js.)*
294
+ {
295
+ const copies = findSiteCopies(siteDir, client.origin)
296
+ if (copies.length) {
297
+ const said = describeSiteCopies(copies, client.origin, 'publish')
298
+ say.err(said.headline)
299
+ for (const line of said.lines) say.dim(line)
300
+ return { exitCode: 1 }
301
+ }
302
+ }
303
+
304
+ // ⚠️ Logged in to a backend where this project has no site, while it has one
305
+ // elsewhere: following the login CREATES a second site. Say so before the owner
306
+ // question, which otherwise arrives with no reason attached. Not when
307
+ // UNIWEB_SERVER chose the backend — that is already a decision.
308
+ if (!process.env.UNIWEB_SERVER) {
309
+ const known = syncedElsewhere(siteDir, client.origin)
310
+ if (known) {
311
+ const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'publish')
312
+ say.info(headline)
313
+ for (const line of rest) say.dim(line)
314
+ }
315
+ }
316
+
317
+ // ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
318
+ // command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
319
+ // creates the site in it, and a site the backend keeps elsewhere is refused.
320
+ const ws = await resolveWorkspace({ client, args, offline: dryRun })
321
+ if (ws.refused) {
322
+ say.err('This publish works in one workspace, and none is chosen.')
323
+ say.dim(ws.reason)
324
+ return { exitCode: 2 }
325
+ }
326
+
327
+ // ⛔ There is no capability gate here any more, deliberately.
328
+ //
329
+ // This used to read `delivery.publish` from the discovery document and refuse when it
330
+ // was false. It could never fire: the backend sent a literal true for every deployment,
331
+ // so the gate compared a constant against false. The key is now gone on both sides
332
+ // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
333
+ // while implying a capability that was never negotiable.
334
+ //
335
+ // Discovery is not consulted for a gate: the CLI reads the key lists under `siteContent`
336
+ // (`queryFields`, `pageFields`, `settingsFields`), which say what the push below may SEND
337
+ // (`deploymentFields`), never whether to publish. (`delivery.siteSubscriptionRequired`
338
+ // was deleted 2026-08-30; this said it was still read, after the site create, until
339
+ // 2026-09-26.)
340
+
341
+ // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
342
+ // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
343
+ // longer a query param on publish. Do not reintroduce either.
344
+ //
345
+ // The chain is SITE → FOUNDATION → RUNTIME. A link-mode site is CODELESS: it
346
+ // ships no JS, so it has nothing that binds to a runtime version and cannot
347
+ // break when the runtime moves. Asking it to name one is not a hard question,
348
+ // it is a malformed one. The party that binds is the FOUNDATION, whose build
349
+ // externalizes react / react-dom / jsx-runtime / @uniweb/core — which is why
350
+ // the compatibility floor rides on the foundation (`dist/runtime-pin.json` →
351
+ // `info.runtime`, stated on every register) and never here. Whoever resolves a
352
+ // whole site picks a runtime satisfying max() of the floors its foundation and
353
+ // extensions declare, and fetches it from a CDN — the official mirror, the
354
+ // distribution channel, or a local server. A backend never holds one.
355
+ //
356
+ // History, because each removal undid a different bad idea:
357
+ // • a local fallback to the highest version the backend reported installed —
358
+ // the producer guessing at a fact the control plane owns, and actively
359
+ // wrong once propagation moves sites (a walk advances a site to X; the
360
+ // next publish would restate a locally-computed value, silently undoing
361
+ // it). Removed in `6d32d94`.
362
+ // • fail-closed validation of a pin against `/dev/config`'s
363
+ // `runtime.installed`, and the `uniweb runtime register` verb that
364
+ // populated that field. Removed 2026-08-22 with the concept.
365
+ // • the pin itself, and the `?runtime=` it rode on. Removed 2026-08-22 —
366
+ // nothing scaffolded ever set it, it is in no public doc, and the backend
367
+ // may already have been ignoring it.
368
+
369
+ // deploy.yml target (the Uniweb hosting memory) — the one for the backend this
370
+ // publish goes to. ⭐ The target follows the backend, never the reverse: the default
371
+ // target only breaks a tie (resolvePublishTarget). With no deploy.yml, or no target
372
+ // naming this backend, persistLastDeploy adds one.
373
+ //
374
+ // ⛔ This took deploy.yml's DEFAULT target until 2026-09-21, whatever backend it was
375
+ // publishing to — a publish to a second backend wrote over the default target's
376
+ // record and compared its request against that target's fingerprint.
377
+ let resolved
378
+ let priorRequest = null
379
+ try {
380
+ const deployYml = await loadDeployYml(siteDir)
381
+ resolved = resolvePublishTarget(deployYml, client.origin, {
382
+ defaultBackend: DEFAULT_BACKEND_ORIGIN
383
+ })
384
+ // The language selection last sent TO THIS BACKEND, for its reconcile below. Read
385
+ // from the SAME deploy.yml load — one read, and the memo is the only durable record
386
+ // of it (see backend/service-request.js).
387
+ priorRequest = deployYml?.deploys?.[resolved.targetName] || null
388
+ } catch {
389
+ // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
390
+ resolved = {
391
+ targetName: 'production',
392
+ host: 'uniweb',
393
+ config: {},
394
+ saveDeploys: true,
395
+ fromFile: false
396
+ }
397
+ }
398
+ const saveDeploys = !noSave && resolved.saveDeploys !== false
399
+
400
+ // A SITE-RELATIVE extension URL cannot work on Uniweb hosting: the published
401
+ // site ships no JS, so nothing serves that path. The request falls through to
402
+ // the SPA shell and returns 200 with `text/html`, which `import()` then fails
403
+ // to parse — and `loadExtensions` uses Promise.allSettled, so nothing throws
404
+ // and every section the extension provides silently renders "Component not
405
+ // found". A 200-with-HTML is strictly worse to debug than a 404 (the same
406
+ // shape that forced the `/data/` carve-out at the edge), so fail here, at the
407
+ // author's screen, rather than at a visitor's.
408
+ //
409
+ // `export` / `deploy --host` are unaffected — there the site serves its own
410
+ // files and a relative URL is exactly right.
411
+ const relativeExtensions = (
412
+ Array.isArray(siteYml.extensions) ? siteYml.extensions : []
413
+ )
414
+ .map((e) => (e && typeof e === 'object' ? e.url || e.ref || e.name : e))
415
+ .filter((d) => isSiteRelativeExtensionUrl(d))
416
+ if (relativeExtensions.length) {
417
+ say.err(
418
+ `Site-relative extension URL${relativeExtensions.length > 1 ? 's' : ''} cannot be served by Uniweb hosting: ${relativeExtensions.join(', ')}`
419
+ )
420
+ say.dim(
421
+ 'A published site ships no JS, so nothing serves that path. An extension is a foundation —'
422
+ )
423
+ say.dim(
424
+ 'register it (`uniweb register` in the extension directory) and reference it by name or'
425
+ )
426
+ say.dim(
427
+ '`@org/name@version` in site.yml::extensions, the same way the primary foundation is declared.'
428
+ )
429
+ say.dim(
430
+ 'Site-relative URLs keep working with `uniweb export` and `uniweb deploy --host=<adapter>`.'
431
+ )
432
+ return { exitCode: 1 }
433
+ }
434
+
435
+ if (dryRun) {
436
+ say.info('Dry run — would bring the foundation along, sync, and go live:')
437
+ say.dim(`Backend : ${client.origin}`)
438
+ say.dim(
439
+ `site_uuid : ${readBackendState(siteDir, client.origin).site?.uuid || '(none — the site is created before anything uploads)'}`
440
+ )
441
+ const langs = languagesFromSiteYml(siteYml)
442
+ if (langs) say.dim(`Languages : ${langs.join(', ')}`)
443
+ await bringFoundationAlong({
444
+ client,
445
+ siteDir,
446
+ siteYml,
447
+ args,
448
+ say,
449
+ confirm,
450
+ cliBin: process.argv[1],
451
+ dryRun: true
452
+ })
453
+ await bringExtensionsAlong({
454
+ client,
455
+ siteDir,
456
+ siteYml,
457
+ args,
458
+ say,
459
+ confirm,
460
+ cliBin: process.argv[1],
461
+ dryRun: true
462
+ })
463
+ // No payment line on a dry run. The backend is the only gate and it
464
+ // answers at go-live, so the honest dry-run answer is silence rather than
465
+ // a guess. (The old "would check whether go-live needs payment" described
466
+ // a pre-flight probe that has been removed — payment-handoff.js.)
467
+ return { exitCode: 0 }
468
+ }
469
+
470
+ // 1. Bring the foundation along — release the local foundation if its code
471
+ // changed (or isn't registered). Never ship a site pointing at stale code.
472
+ let fnd
473
+ try {
474
+ fnd = await bringFoundationAlong({
475
+ client,
476
+ siteDir,
477
+ siteYml,
478
+ args,
479
+ say,
480
+ confirm,
481
+ cliBin: process.argv[1]
482
+ })
483
+ } catch (err) {
484
+ // A backend that did not answer is said as that — not as a release that failed.
485
+ if (err instanceof BackendUnreachableError) {
486
+ say.err(err.message)
487
+ say.dim('Nothing was released or sent. Check that the backend is running, then re-run `uniweb publish`.')
488
+ return { exitCode: 1 }
489
+ }
490
+ // A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
491
+ say.err(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
492
+ for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb publish`.']) say.dim(line)
493
+ return { exitCode: 1 }
494
+ }
495
+
496
+ // 1b. Same for the site's LOCAL extensions. An extension is a foundation, so
497
+ // it gets the same freshness guarantee — otherwise a site could go live
498
+ // against stale extension code with nothing noticing.
499
+ let ext
500
+ try {
501
+ ext = await bringExtensionsAlong({
502
+ client,
503
+ siteDir,
504
+ siteYml,
505
+ args,
506
+ say,
507
+ confirm,
508
+ cliBin: process.argv[1]
509
+ })
510
+ } catch (err) {
511
+ say.err(err.notMember ? err.message : `Extension release failed: ${err.message}`)
512
+ for (const line of err.notMember ? err.ways : ['Fix the extension, then re-run `uniweb publish`.']) say.dim(line)
513
+ return { exitCode: 1 }
514
+ }
515
+ if (!ext.proceed) return { exitCode: 1 }
516
+ // A human who answered "no" chose this, so it is not a failure — exit 0. A
517
+ // REFUSAL is different: nobody was asked, nothing shipped, and the caller is
518
+ // usually an agent that reads the exit code and reports done. Exit 0 there would
519
+ // be the silent-wrong-success this whole branch exists to prevent.
520
+ if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
521
+
522
+ // ⭐ Every site request names the workspace — the create, the push, the publish and
523
+ // the data lane — and a site outside it stops the publish (`WorkspaceMismatchError`).
524
+ // A site this publish creates is created in it — said first.
525
+ client.setWorkspace(ws.workspace, { source: ws.source })
526
+ if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
527
+ say.dim(`This publish creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
528
+ }
529
+
530
+ // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
531
+ // dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
532
+ // build can't resolve to a different installed version.
533
+ //
534
+ // ⛔ NO SEARCH INDEX. This listed `dist/_search/*` until 2026-08-26; the
535
+ // link lane stopped emitting one on 2026-08-01 (`@uniweb/build`
536
+ // `site/build-site-data.js` step 5) because only one of the two
537
+ // publishers produced it.
538
+ say.info('Building site…')
539
+ console.log('')
540
+ execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
541
+ cwd: siteDir,
542
+ stdio: 'inherit',
543
+ env: process.env
544
+ })
545
+ console.log('')
546
+
547
+ const distDir = join(siteDir, 'dist')
548
+ const contentPath = join(distDir, 'site-content.json')
549
+ if (!existsSync(contentPath)) {
550
+ say.err('Build did not produce dist/site-content.json')
551
+ return { exitCode: 1 }
552
+ }
553
+
554
+ // Non-local @std/registry Model schemas resolve through the backend (same as push).
555
+ const resolveModel = makeModelResolver({ client, offline: false, siteDir })
556
+
557
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
558
+ // is destructive — a directory emptied by accident, or kept with only a
559
+ // placeholder — so the count is reported and confirmed before anything is sent.
560
+ //
561
+ // ⛔ `backend` WAS MISSING HERE until 2026-09-21, and it is what the count is read
562
+ // under: placements are banked per backend, so with none the guard counted zero
563
+ // and never asked — `publish` sent an empty folder without a word while `push`
564
+ // stopped for the same one.
565
+ {
566
+ const guard = await guardEmptyRecords({
567
+ siteDir,
568
+ backend: client.origin,
569
+ args,
570
+ warn: say.warn,
571
+ note: say.dim
572
+ })
573
+ if (!guard.ok) return { exitCode: 1 }
574
+ }
575
+
576
+ // 3. Partition collections by schema presence (a first emit reads `schemaless`
577
+ // — collections with no data schema, delivered statically via the ball).
578
+ let probe
579
+ try {
580
+ probe = await emitSyncPackages(siteDir, {
581
+ backend: client.origin,
582
+ // Placement identity for the folder — see writeFolderItemUuids.
583
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
584
+ ...(foundationDir ? { foundationDir } : {}),
585
+ resolveModel
586
+ })
587
+ } catch (err) {
588
+ say.err(`Could not build the sync package: ${err.message}`)
589
+ return { exitCode: 1 }
590
+ }
591
+ // A record the backend would refuse stops the publish HERE — before the site is
592
+ // created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
593
+ if (refuseUnsendableRecords(probe.refusals, { error: say.err, note: say.dim })) {
594
+ return { exitCode: 1 }
595
+ }
596
+ const schemalessNames = (probe.schemaless || []).map((col) => col.name)
597
+ // A product decision the author is usually making unknowingly — say it at warn
598
+ // level, not dim among everything else. See the helper for what the old
599
+ // message got wrong.
600
+ reportSchemalessQueries(probe.schemaless, say)
601
+ const localAssets = probe.localAssets || []
602
+
603
+ // 3a. A clone with no `$uuid` is bound to no backend site, so every cached map
604
+ // that describes one is stale — including after the documented "clear
605
+ // `$uuid` to re-publish as a new site" recovery. Must run BEFORE the create,
606
+ // which mints a uuid and would make the clone look bound.
607
+ const droppedState = clearRemoteSyncStateIfUnbound(siteDir, client.origin)
608
+ if (droppedState.length) {
609
+ say.dim(
610
+ `Cleared stale sync state from a previous site (${droppedState.join(', ')}).`
611
+ )
612
+ }
613
+ const droppedValues = dropSiteBoundValues(siteDir, client.origin)
614
+ if (droppedValues.length) {
615
+ say.dim(
616
+ `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
617
+ )
618
+ }
619
+
620
+ // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
621
+ //
622
+ // Uploaded bytes are metered against an owning entity and reclaimed by
623
+ // deleting it. Uploading before the site exists therefore produces bytes
624
+ // that are charged and can never be freed — there is nothing to delete —
625
+ // so a repeatedly-failing first publish would burn quota with no recovery
626
+ // short of support. Creating the site first makes the artifact of a failed
627
+ // publish an EMPTY SITE instead: it costs nothing to keep and the owner can
628
+ // clear it. A no-op once `$uuid` is set, so only a first publish pays.
629
+ //
630
+ // This ordering is load-bearing, not incidental — the asset plan requires an
631
+ // owner. A test asserts the create precedes the upload.
632
+ const site = await ensureSiteExists({
633
+ client,
634
+ siteDir,
635
+ name: siteYml.name,
636
+ foundation: fnd.ref || siteYml.foundation,
637
+ note: (m) => say.dim(m)
638
+ })
639
+ if (!site.uuid) {
640
+ say.err(`Could not create the site on the backend: ${site.reason}`)
641
+ say.dim('Nothing was uploaded and nothing was charged.')
642
+ return { exitCode: 1 }
643
+ }
644
+
645
+ // 4. Gather the schema-less data files BEFORE uploading media, since their
646
+ // records can carry local media too. (No search index rides here since
647
+ // 2026-08-01, and nothing is bundled — `build/src/site/schemaless-data.js`.)
648
+ let ball = await collectSchemalessData(distDir, schemalessNames)
649
+ const ballAssets = collectSchemalessDataAssets(ball)
650
+
651
+ // 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
652
+ // the ref→serveUrl map; rewrite the entity content AND the ball with it.
653
+ let assetRewrite = null
654
+ let assetIds = null
655
+ // …and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`, already located).
656
+ const mediaRefs = [...new Set([...localAssets, ...ballAssets]), ...(probe.localFiles || [])]
657
+ if (mediaRefs.length) {
658
+ say.info('Uploading media…')
659
+ try {
660
+ const { map, ids, failed } = await uploadSiteMedia(
661
+ client,
662
+ siteDir,
663
+ mediaRefs,
664
+ {
665
+ siteUuid: site.uuid,
666
+ onProgress: (m) => say.dim(` ${m}`),
667
+ warn: (m) => say.dim(`! ${m}`)
668
+ }
669
+ )
670
+ // A ref whose bytes did not land must NOT be published: the content would go
671
+ // out still pointing at the local path, so the site ships a broken image and
672
+ // the only trace is a warning nobody reads. A missing FILE is different —
673
+ // already broken before us, warned above, and not worth blocking a publish.
674
+ if (failed.length) {
675
+ say.err(`${failed.length} asset(s) failed to upload — not publishing.`)
676
+ for (const f of failed) say.dim(` ${f.path} (HTTP ${f.status})`)
677
+ return { exitCode: 1 }
678
+ }
679
+ if (Object.keys(map).length) assetRewrite = map
680
+ if (Object.keys(ids).length) assetIds = ids
681
+ // Identity into the COMMITTED map — see backend/asset-map.js. Merge, not
682
+ // replace: this publish carries only the refs its content touched.
683
+ const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
684
+ if (rec.written) {
685
+ say.dim(
686
+ `${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed — commit it`
687
+ )
688
+ }
689
+ if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
690
+ say.dim(
691
+ `Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`
692
+ )
693
+ } catch (err) {
694
+ // A typed plan refusal gets its own account (quota, per-file cap, plan caps);
695
+ // anything else falls through to the raw message. Nothing has been pushed at
696
+ // this point, so either way the site is untouched.
697
+ const refusal = describeAssetRefusal(err)
698
+ if (refusal) {
699
+ say.err(refusal.headline)
700
+ for (const line of refusal.notes) say.dim(line)
701
+ } else {
702
+ say.err(`Media upload failed: ${err.message}`)
703
+ }
704
+ return { exitCode: 1 }
705
+ }
706
+ }
707
+
708
+ // 4c. Deliver the schema-less collection data — one object per file.
709
+ //
710
+ // Each `dist/data/**` file is PUT to the target `data-uploads` returns,
711
+ // landing at its serving tail. Hosting intercepts `/data/**.json` and
712
+ // reads `_data/{tail}` from the site bucket, so the file is served from
713
+ // where it lands: **nothing records where anything went**, and no `info`
714
+ // field is stamped.
715
+ //
716
+ // ⛔ NO BALL. A CLI sends separates or a ball, never both — presence of a
717
+ // ball is the backend's signal for which CLI it is talking to, and
718
+ // sending both destroys it along with their ability to know when the
719
+ // unwrap can be deleted. Released CLIs keep sending one and their unwrap
720
+ // keeps serving them; this one does not.
721
+ //
722
+ // 📌 No capability gate, deliberately. A backend predating this lane
723
+ // answers 404 and the publish fails — accepted while pre-prod [Diego,
724
+ // 2026-08-18: "we are pre-prod. I'm not concerned about old cli vs new"].
725
+ // A gate was written and removed: it is machinery for a population that
726
+ // does not exist, which is the failure this work kept catching in others.
727
+ // `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
728
+ // makes an absent key non-breaking by construction.
729
+ //
730
+ // ⛔ UNCONDITIONAL — `if (ball)` was here until 2026-09-01 and it was the bug.
731
+ //
732
+ // `collectSchemalessData` returns null for an empty set, so a publish that
733
+ // carried no schema-less data sent NO PLAN AT ALL. The backend reconciles a
734
+ // site's data usage against this manifest, and a request that never arrives
735
+ // is not a manifest saying "none" — it is silence, indistinguishable from a
736
+ // publish that never happened. So deleting your LAST schema-less collection
737
+ // — the exact operation the reconcile exists to make free — was the one
738
+ // operation that could not be expressed, and the site kept paying for it
739
+ // until the whole site was deleted.
740
+ //
741
+ // ⭐ The general shape, worth more than the fix: an EMPTY set and NO set are
742
+ // different statements, and an `if (x)` guard collapses them into one. The
743
+ // cost is always paid by whoever is downstream trying to tell them apart.
744
+ //
745
+ // Both halves agreed in channel backend↔framework (2026-09-01); the
746
+ // backend's route accepts an empty `files` array as of the same exchange.
747
+ say.info('Uploading schema-less record data…')
748
+ try {
749
+ const r = await uploadSiteData({
750
+ apiBase: client.origin,
751
+ token: await client.token(),
752
+ siteUuid: site.uuid,
753
+ // The plan and every direct PUT name the workspace the rest of the publish did.
754
+ workspace: client.workspace,
755
+ ball,
756
+ onProgress: (m) => say.dim(` ${m}`)
757
+ })
758
+ if (r.failed.length) {
759
+ // A file whose bytes did not land must not be published: the site would
760
+ // serve a stale copy or 404, and the only trace would be a warning.
761
+ say.err(
762
+ `${r.failed.length} data file(s) failed to upload — not publishing.`
763
+ )
764
+ for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
765
+ return { exitCode: 1 }
766
+ }
767
+ say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
768
+ } catch (err) {
769
+ // A typed plan refusal gets its own account (quota, or whatever else the
770
+ // backend names); anything else falls through to the raw message. Same
771
+ // treatment the asset plan and the site create already get — this was the
772
+ // last door still printing the problem document at the user verbatim.
773
+ const refusal = describeDataRefusal(err)
774
+ if (refusal) {
775
+ say.err(refusal.headline)
776
+ for (const line of refusal.notes) say.dim(line)
777
+ } else {
778
+ say.err(`Record data upload failed: ${err.message}`)
779
+ }
780
+ return { exitCode: 1 }
781
+ }
782
+
783
+ // 5. Push the site (content + folder) over the send-only-changed cache —
784
+ // the SAME two-lane submission `uniweb push` uses — stamping
785
+ // the pinned foundation ref and rewriting local media refs to serve URLs.
786
+ // (It stamped `info.data_bundle` until 2026-08-18; the ball is gone and
787
+ // collection data now lands at its serving tail, so nothing records it.)
788
+ const priorHashes = readSyncCache(siteDir, client.origin)
789
+ // Per-item identity, recovered from the backend when this clone has never seen it.
790
+ // Without it the backend re-mints every page and section row (see readItemUuids).
791
+ const itemUuids = await ensureItemUuids({
792
+ client,
793
+ siteDir,
794
+ note: (m) => say.dim(m)
795
+ })
796
+ // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
797
+ // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
798
+ // Delivery is version-pinned end-to-end (the gateway serves a foundation only
799
+ // by a concrete version — collab framework↔backend), so pinning the
800
+ // released version on the wire is required when site.yml uses an unversioned
801
+ // local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
802
+ // is null → the site.yml ref is forwarded verbatim (already pinned).
803
+ // ⛔ DO NOT STAMP A FILE MAP INTO `info` HERE. `injectInfo` WINS the merge
804
+ // (`sync-package.js`: `{...siteDoc.info, ...injectInfo}`), so any name stamped
805
+ // here silently replaces what the author's document carries under it, and the
806
+ // store validator accepts it — nothing errors at any layer. This was written
807
+ // about `info.data`, which carried the site's `fetch:` block until 2026-09-09;
808
+ // that moved to `settings.fetch`, and the warning holds for any name.
809
+ //
810
+ // A file map needs a name nothing else claims (`static_data` / `data_files`
811
+ // were proposed) AND a consumer that reads it — neither settled. See
812
+ // one object per file, no bundle.
813
+ const injectInfo = {
814
+ ...(fnd.ref ? { foundation: fnd.ref } : {})
815
+ }
816
+ // ⭐ WHAT THE SITE HAS — read before the push, for the language selection. Only this
817
+ // read sees a decision the owner made in the app since this clone last synced. It
818
+ // reads this backend's site, from sync.json; a never-synced site has none.
819
+ const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
820
+ const status = boundUuid ? await client.siteStatus(boundUuid) : null
821
+
822
+ // ⭐ THE SERVICES are stated by the push itself — the file's, and off for each held
823
+ // one it no longer lists (`statedServices`) — and the backend decides per service.
824
+ // Said here: what the file asks that will not be sent as written.
825
+ announceServices({ siteYml, say, supports: await foundationSupports(siteDir, siteYml) })
826
+
827
+ // ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
828
+ //
829
+ // `publishLanguages` is a request like `services`: pushed up, projected back on
830
+ // pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
831
+ // today — which is why this was nearly skipped — but a base is not only for
832
+ // detecting an overwrite. Without one, a selection that has always been in the
833
+ // file cannot be told from one the owner just typed, and for languages that is a
834
+ // charge: the line is billed on how many go out, so adding one costs money and
835
+ // the owner should hear that from us before it is sent, not from a refusal after.
836
+ if (status && !isNonInteractive(args)) {
837
+ const langs = reconcile(
838
+ siteYml.publishLanguages,
839
+ status.publish_languages,
840
+ priorRequest?.publishLanguagesRequest
841
+ )
842
+ if (langs.action === 'send') {
843
+ const asked = siteYml.publishLanguages
844
+ say.info(
845
+ `You changed which languages this site publishes: ${describeLanguages(asked)}.`
846
+ )
847
+ // ⚖️ "may" — the count is what is priced, and only the backend prices it.
848
+ // Framework says a charge is possible and never how much: this package is
849
+ // public and holds no prices, and a number we invented would be wrong.
850
+ say.dim(' Adding a language may cost more. You will be asked to confirm if so.')
851
+ } else if (langs.action === 'adopt' || langs.action === 'conflict') {
852
+ say.info('This site publishes different languages than site.yml lists.')
853
+ say.dim(` in site.yml: ${describeLanguages(siteYml.publishLanguages)}`)
854
+ say.dim(` on your site: ${describeLanguages(status.publish_languages)}`)
855
+ }
856
+ }
857
+
858
+ // publish rides the same gated push as `uniweb push`: if an app author has
859
+ // edited since this clone last synced, the push is refused rather than
860
+ // overwriting them, and nothing goes live. `--force` drops the precondition.
861
+ const forced = args.includes('--force')
862
+ const fields = await deploymentFields({ client, siteDir })
863
+ // A foundation named by catalog ref — a clone's: its kept registered version types a query named
864
+ // for a data key (`ensureRegisteredFoundation`).
865
+ await ensureRegisteredFoundation({
866
+ client,
867
+ siteDir,
868
+ siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
869
+ ref: siteYml?.foundation
870
+ })
871
+ const emitOptions = {
872
+ backend: client.origin,
873
+ // The keys this deployment's Sections take — see deploymentFields.
874
+ ...fields,
875
+ // Placement identity for the folder — see writeFolderItemUuids.
876
+ folderItemUuids: readFolderItemUuids(siteDir, client.origin),
877
+ // Identity for the records' list items — see readRecordItemUuids.
878
+ recordItemUuids: readRecordItemUuids(siteDir, client.origin),
879
+ ...(foundationDir ? { foundationDir } : {}),
880
+ resolveModel,
881
+ priorHashes,
882
+ itemUuids,
883
+ ...(forced
884
+ ? {}
885
+ : {
886
+ baseVersions: readBaseVersions(siteDir, client.origin),
887
+ itemBaseVersions: readItemBaseVersions(siteDir, client.origin)
888
+ }),
889
+ ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
890
+ ...(Object.keys(ext.pins).length ? { injectExtensions: ext.pins } : {}),
891
+ ...(assetRewrite ? { assetRewrite } : {}),
892
+ ...(assetIds ? { assetIds } : {})
893
+ }
894
+ let pkg
895
+ try {
896
+ pkg = await emitSyncPackages(siteDir, emitOptions)
897
+ // Identity the backend holds and this copy lacks — see `uniweb push`.
898
+ const again = await recoverUnbankedIdentity({ client, siteDir, pkg, note: (m) => say.dim(m) })
899
+ if (again) pkg = await emitSyncPackages(siteDir, { ...emitOptions, ...again })
900
+ } catch (err) {
901
+ say.err(`Could not build the sync package: ${err.message}`)
902
+ return { exitCode: 1 }
903
+ }
904
+ for (const w of pkg.warnings) say.dim(`! ${w}`)
905
+ if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
906
+ return { exitCode: 1 }
907
+ }
908
+ // ⭐ `records` is what delivers the records the pages show on the published site — said
909
+ // here, at publish, and never at push: syncing records does not depend on it.
910
+ const notAsked = recordsNotAsked({ siteYml, shown: pkg.recordsShown })
911
+ if (notAsked) say.warn(notAsked)
912
+ const report = {
913
+ info: (m) => say.info(m),
914
+ note: (m) => say.dim(m),
915
+ error: (m) => say.err(m),
916
+ dim: (s) => `${c.dim}${s}${c.reset}`
917
+ }
918
+ const pushResult = await pushSyncPackages({
919
+ client,
920
+ siteDir,
921
+ pkg,
922
+ report
923
+ })
924
+ if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
925
+ const siteUuid = pushResult.boundSiteUuid
926
+ if (!siteUuid) {
927
+ say.err('Push did not yield a site uuid — cannot go live.')
928
+ return { exitCode: 1 }
929
+ }
930
+
931
+ // 7. Go live — make the just-pushed composite live (its current backend state).
932
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
933
+ const languages = languagesFromContent(siteContent, dirname(contentPath))
934
+ say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
935
+ let pubRes
936
+ try {
937
+ pubRes = await client.publishSite(siteUuid, {
938
+ ...(languages ? { languages } : {})
939
+ })
940
+ } catch (err) {
941
+ say.err(describeRequestError(err, client.origin))
942
+ if (!(err instanceof WorkspaceMismatchError))
943
+ say.dim('Is that the backend you meant? Switch with: uniweb login --server <url>')
944
+ return { exitCode: 1 }
945
+ }
946
+ // The body of a publish that did not go through — read once, and kept for the report below.
947
+ let failedBody = ''
948
+ if (!pubRes.ok) {
949
+ failedBody = await pubRes.text().catch(() => '')
950
+
951
+ // A 402 is the backend's payment gate — the ONLY gate, evaluated here on
952
+ // every publish against whatever posture that deployment runs. It is a
953
+ // refusal, not a fault: the content is already synced as a draft, so the
954
+ // recovery is to settle and re-run. Give it the backend's own sentence
955
+ // rather than the raw envelope — and when it offers a change the terminal
956
+ // may confirm, ask, and publish again with the answer.
957
+ const refusal = readPaymentRefusal({
958
+ status: pubRes.status,
959
+ contentType: pubRes.headers?.get?.('content-type') || '',
960
+ body: failedBody
961
+ })
962
+ if (refusal.kind !== 'not-payment') {
963
+ const settled = await settleRefusal({
964
+ verdict: refusal,
965
+ args,
966
+ say,
967
+ republish: (token, { unattended } = {}) =>
968
+ client.publishSite(siteUuid, { ...(languages ? { languages } : {}), confirm: token, unattended })
969
+ })
970
+ if ('exitCode' in settled) return { exitCode: settled.exitCode }
971
+ pubRes = settled.response
972
+ failedBody = settled.body ?? ''
973
+ }
974
+ }
975
+ if (!pubRes.ok) {
976
+ say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
977
+ if (pubRes.status === 401 || pubRes.status === 403) {
978
+ say.dim(
979
+ "Credentials weren't accepted — run `uniweb login` again."
980
+ )
981
+ }
982
+ if (failedBody) say.dim(failedBody.slice(0, 800))
983
+ return { exitCode: 1 }
984
+ }
985
+ let result
986
+ try {
987
+ result = await pubRes.json()
988
+ } catch {
989
+ result = {}
990
+ }
991
+ // Where the site went live — a finished, absolute address, taken verbatim: shown,
992
+ // and recorded in deploy.yml, never composed onto. Until 2026-09-17 a backend
993
+ // serving the site itself answered with an origin-relative path, which this
994
+ // prefixed with our own origin; the reply is absolute now, so a path from an
995
+ // older backend is simply shown as one.
996
+ const serveUrl = typeof result.url === 'string' && result.url ? result.url : null
997
+
998
+ // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
999
+ // reuses the resolved target without re-asking). Identity is sync.json's,
1000
+ // per backend. `released` records whether this publish shipped a new
1001
+ // foundation version (the bring-along, §4).
1002
+ // Record the ref that actually went live: the pinned `@scope/name@version`
1003
+ // from the bring-along when present, else the site.yml ref verbatim.
1004
+ const gitAt = headProvenance(siteDir)
1005
+ const siteYmlRef =
1006
+ typeof siteYml.foundation === 'string'
1007
+ ? siteYml.foundation
1008
+ : siteYml.foundation?.ref || null
1009
+ const recordedRef = fnd.ref || siteYmlRef
1010
+ await persistLastDeploy(siteDir, {
1011
+ targetName: resolved.targetName,
1012
+ // A target this file does not have yet — the first publish anywhere (the whole
1013
+ // file is scaffolded), or the first to a backend no target names (the target is
1014
+ // added beside the others) — records the backend it went live on.
1015
+ targetConfig: resolved.fromFile
1016
+ ? null
1017
+ : { host: 'uniweb', backend: client.origin },
1018
+ saveDeploys,
1019
+ lastDeploy: {
1020
+ at: new Date().toISOString(),
1021
+ host: 'uniweb',
1022
+ // The language selection this publish sent — the base its reconcile compares
1023
+ // against next time. ⛔ A FINGERPRINT: the selection itself is in site.yml.
1024
+ ...bankLanguages(siteYml),
1025
+ // What was actually shipped. A version number can't answer that — two
1026
+ // publishes of "0.1.0" are not the same content — and after the fact the
1027
+ // working tree has moved on. `dirty` matters as much as the sha: it says the
1028
+ // publish did NOT correspond to any commit, so the sha alone would mislead.
1029
+ ...(gitAt ? { git: gitAt } : {}),
1030
+ backend: client.origin,
1031
+ siteUuid,
1032
+ url: serveUrl,
1033
+ foundation: {
1034
+ ...(recordedRef ? { ref: recordedRef } : {}),
1035
+ released: fnd.released
1036
+ },
1037
+ // No `runtime` here. `deploy.yml` records what this publish DID, and a
1038
+ // publish sends nothing about a runtime — the site's runtime follows from
1039
+ // its foundation's floor and is resolved by whoever serves it. Recording a
1040
+ // value would be a snapshot that silently goes stale, of a decision this
1041
+ // command does not make.
1042
+ // ⛔ WHAT WENT OUT, or nothing. This fell back to `languages` — what we
1043
+ // ASKED FOR — when the response carried no `locales`, which put two
1044
+ // different facts under one key with no way to tell them apart: a reader
1045
+ // could not distinguish "these were served" from "we asked for these and
1046
+ // were never told". `deploy.yml` records what a publish DID, and when we do
1047
+ // not know what it did, the honest record is silence.
1048
+ ...(Array.isArray(result.locales) ? { locales: result.locales } : {})
1049
+ }
1050
+ })
1051
+
1052
+ console.log('')
1053
+ say.ok(
1054
+ `Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`
1055
+ )
1056
+ // What the request changed, in the backend's own sentences — a confirmed change, or a reduction
1057
+ // (`applied`, its §4A). Absent when nothing changed.
1058
+ for (const line of Array.isArray(result.applied) ? result.applied : []) {
1059
+ if (typeof line === 'string' && line) say.info(line)
1060
+ }
1061
+
1062
+ // ⛔ SAY IT WHEN A LANGUAGE ASKED FOR DID NOT GO OUT.
1063
+ //
1064
+ // The publish succeeded, so nothing above this is wrong — and a site live in two
1065
+ // of the three languages its author listed looks exactly like a site live in the
1066
+ // two they wanted. That is the failure with no owner: the file and reality
1067
+ // disagree and the terminal is green.
1068
+ //
1069
+ // ⚖️ A warning, not a failure. The publish DID happen and the content IS live, so
1070
+ // exiting non-zero would tell a script the deploy failed when it did not.
1071
+ const unserved = unservedLanguages(languages, result.locales)
1072
+ if (unserved.length > 0) {
1073
+ say.warn(
1074
+ `Your site went live in ${(result.locales || []).join(', ')} — but not ${unserved.join(', ')}.`
1075
+ )
1076
+ say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
1077
+ }
1078
+ if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
1079
+ // ⛔ No site.yml write for where it went live. That is a fact about this deploy,
1080
+ // not about the site, and it is recorded where deploy facts live: deploy.yml's
1081
+ // `lastDeploy.<target>.url` (step 8 above). site.yml carried it as `$url` ⇄
1082
+ // `info.url` from 2026-09-10 until the field was retired on 2026-09-17.
1083
+ if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
1084
+ return { exitCode: 0 }
1085
+ }
1086
+
1087
+ export default publish