uniweb 0.12.35 → 0.12.37

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.
@@ -1,45 +1,63 @@
1
1
  /**
2
- * uniweb publish — make a SYNCED site's current backend state live (CMS publish).
2
+ * uniweb publish — the smart Uniweb-hosting flagship (shipping-model.md §3).
3
3
  *
4
- * Three "publish-ish" verbs, three jobs don't conflate them:
5
- * - `uniweb deploy` hosts the CLI's FILE-BUILT payload (POST /dev/deploy).
6
- * - `uniweb publish` publishes a SITE that already lives on the backend as a
7
- * `@uniweb/site-content` entity (synced via `uniweb push`) — POST /dev/site/publish.
8
- * - `uniweb register` registers a FOUNDATION (+ the data schemas it renders).
9
- * (Foundation publishing used to be `uniweb publish`; it is now `register`.)
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:
10
8
  *
11
- * `publish` makes the site's CURRENT backend state live including edits made
12
- * through the app since the last push. It does NOT push local files (run
13
- * `uniweb push` first if you want your local edits live, then `publish`). The two
14
- * are deliberately separate steps, mirroring the directional sync primitives.
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 + the static-data
15
+ * ball, and pushes content (the same two-lane sync `uniweb push` uses);
16
+ * 4. SETTLES PAYMENT when the backend says go-live needs it — opens a browser
17
+ * to uniweb.app, waits, continues (provider-agnostic; payment-handoff.js);
18
+ * 5. GOES LIVE — POST /dev/site/publish/{uuid}.
15
19
  *
16
- * `{uuid}` is the site-content uuid (`site.yml::$uuid`, written by `uniweb push`).
17
- * A site that was never pushed 404s — push it first, or use `uniweb deploy` for a
18
- * file-only site.
20
+ * Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
21
+ * (foundation code catalog). For a self-contained artifact, see `uniweb export`.
19
22
  *
20
- * Usage:
21
- * uniweb publish Publish the synced site's current state
22
- * uniweb publish --backend <url> Override the backend origin
23
- * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
24
- * uniweb publish --dry-run Resolve everything; POST nothing
23
+ * Backend: BackendClient. Origin from --backend/--registry > UNIWEB_REGISTER_URL
24
+ * > default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
25
25
  *
26
- * Backend: BackendClient → POST /dev/site/publish/{uuid}. Origin from
27
- * --backend/--registry > UNIWEB_REGISTER_URL > default. Auth: --token >
28
- * UNIWEB_TOKEN > `uniweb login`.
26
+ * Usage:
27
+ * uniweb publish Bring the foundation along, sync, and go live
28
+ * uniweb publish --dry-run Resolve everything; POST nothing
29
+ * uniweb publish --yes Skip confirmations (CI); never block on a prompt
30
+ * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
31
+ * uniweb publish --backend <url> Override the backend origin
32
+ * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
29
33
  */
30
34
 
31
- import { existsSync } from 'node:fs'
35
+ import { existsSync, readFileSync } from 'node:fs'
32
36
  import { readFile } from 'node:fs/promises'
33
37
  import { join } from 'node:path'
38
+ import { execSync } from 'node:child_process'
39
+ import { createInterface } from 'node:readline/promises'
34
40
  import yaml from 'js-yaml'
35
41
 
36
- import { createInterface } from 'node:readline/promises'
42
+ import {
43
+ loadDeployYml,
44
+ resolveTarget,
45
+ recordLastDeploy,
46
+ assembleDataBall,
47
+ collectBallAssets,
48
+ rewriteBallAssets,
49
+ } from '@uniweb/build/site'
50
+ import { emitSyncPackages } from '@uniweb/build/uwx'
37
51
 
38
52
  import { BackendClient } from '../backend/client.js'
39
- import { resolveSiteDir } from './deploy.js'
53
+ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
40
54
  import { readFlagValue } from '../utils/args.js'
41
55
  import { isNonInteractive } from '../utils/interactive.js'
42
- import { probeUnpushed } from '../backend/site-sync.js'
56
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
57
+ import { uploadDataBundle } from '../backend/data-bundle.js'
58
+ import { uploadSiteMedia } from '../backend/site-media.js'
59
+ import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
60
+ import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
43
61
 
44
62
  const c = {
45
63
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -66,7 +84,7 @@ async function confirm(question, defaultYes = false) {
66
84
  }
67
85
 
68
86
  // Highest installed runtime from the backend's /dev/config list (numeric-aware
69
- // sort). Mirrors deploy.js's resolver. Null when the list is empty.
87
+ // sort). Null when the list is empty.
70
88
  function pickHighestRuntime(installed) {
71
89
  if (!Array.isArray(installed) || installed.length === 0) return null
72
90
  return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
@@ -79,10 +97,26 @@ function absolutizeServeUrl(origin, url) {
79
97
  return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
80
98
  }
81
99
 
82
- // Locale set from site.yml — source/default first, then declared locales.
83
- // Tolerant of the shapes site.yml uses (i18n.locales, languages[]). Null when
84
- // single-locale, so the body omits `languages` and the backend defaults.
85
- function extractLanguages(siteYml) {
100
+ function readSiteYml(path) {
101
+ if (!existsSync(path)) return {}
102
+ try {
103
+ const parsed = yaml.load(readFileSync(path, 'utf8'))
104
+ return parsed && typeof parsed === 'object' ? parsed : {}
105
+ } catch {
106
+ return {}
107
+ }
108
+ }
109
+
110
+ // Languages from the BUILT site-content.json (config.languages) — the authority
111
+ // after a build. Three accepted shapes: 'en', { value, label }, { code, label }.
112
+ function languagesFromContent(siteContent) {
113
+ const langs = siteContent?.config?.languages
114
+ if (!Array.isArray(langs) || langs.length === 0) return ['en']
115
+ return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
116
+ }
117
+
118
+ // Languages from site.yml — used only for the dry-run summary (no build yet).
119
+ function languagesFromSiteYml(siteYml) {
86
120
  const def = siteYml.defaultLanguage || siteYml.lang || 'en'
87
121
  const locales = siteYml.i18n?.locales || siteYml.languages
88
122
  if (!Array.isArray(locales) || locales.length === 0) return null
@@ -90,122 +124,278 @@ function extractLanguages(siteYml) {
90
124
  return [def, ...norm.filter((l) => l !== def)]
91
125
  }
92
126
 
127
+ // Persist deploy.yml lastDeploy memory (skipped on --no-save / autoSave 'off').
128
+ async function persistLastDeploy(siteDir, opts) {
129
+ if (opts.autoSave === 'off') return
130
+ try {
131
+ const result = await recordLastDeploy(siteDir, opts)
132
+ if (result?.created) say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
133
+ } catch (err) {
134
+ // The publish itself succeeded — never fail the whole command on a
135
+ // memo-write error. Surface it so the user can fix the file.
136
+ say.dim(`Could not update deploy.yml: ${err.message}`)
137
+ }
138
+ }
139
+
93
140
  export async function publish(args = []) {
94
141
  const dryRun = args.includes('--dry-run')
95
- const skipVerify = args.includes('--yes') || args.includes('--force') || args.includes('--no-verify')
96
- const siteDir = await resolveSiteDir(args, 'publish')
142
+ const noSave = args.includes('--no-save')
143
+ const asOrg = readFlagValue(args, '--as-org')
144
+ const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
97
145
 
98
- // The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
99
- // No uuid the site was never synced; publish has nothing to make live.
100
- const siteYmlPath = join(siteDir, 'site.yml')
101
- let siteYml = {}
102
- if (existsSync(siteYmlPath)) {
103
- try {
104
- siteYml = yaml.load(await readFile(siteYmlPath, 'utf8')) || {}
105
- } catch {
106
- siteYml = {}
107
- }
108
- }
109
- const uuid = siteYml.$uuid
110
- if (!uuid) {
111
- say.err('This site has no $uuid in site.yml — it was never synced to the backend.')
112
- say.dim('Run `uniweb push` first (publish makes the synced site live), or use `uniweb deploy` for a file-only site.')
113
- return { exitCode: 1 }
114
- }
146
+ const siteDir = await resolveSiteDir(args, 'publish')
147
+ const siteYml = readSiteYml(join(siteDir, 'site.yml'))
148
+ // The site's deploy.yml-bound backend (where it was published) feeds the
149
+ // resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
150
+ const siteBackend = await resolveSiteBackend(siteDir)
115
151
 
116
152
  const client = new BackendClient({
117
153
  originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
154
+ siteBackend,
118
155
  token: readFlagValue(args, '--token') || undefined,
119
156
  args,
120
157
  command: 'Publishing',
121
158
  })
122
159
 
123
- // Discover + resolve the runtime exactly like deploy: explicit site.yml::runtime,
124
- // else the highest installed (the /dev/config source). Fail closed otherwise.
160
+ // Capability handshake (cached). Publish ends in a go-live, so the publish
161
+ // lane must be offered.
125
162
  const config = await client.discover()
126
163
  if (config?.delivery && config.delivery.publish === false) {
127
164
  say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
128
165
  return { exitCode: 1 }
129
166
  }
167
+
168
+ // Runtime: an explicit site.yml::runtime pin wins; else the highest installed;
169
+ // else fail closed (better than serving a site with no runtime). A dry-run is
170
+ // a pure preview, so it only WARNS — it stays useful with no backend reachable.
130
171
  const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
131
172
  if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
132
173
  say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
133
174
  say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
134
- return { exitCode: 1 }
175
+ if (!dryRun) return { exitCode: 1 }
135
176
  }
136
177
  const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
137
- if (!runtimeVersion) {
178
+ if (!runtimeVersion && !dryRun) {
138
179
  say.err('Could not resolve a runtime version.')
139
180
  say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
140
181
  return { exitCode: 1 }
141
182
  }
142
183
 
143
- const languages = extractLanguages(siteYml)
184
+ // deploy.yml target (the Uniweb hosting memory). No --target on publish — it
185
+ // always targets Uniweb hosting; resolveTarget gives us the target name +
186
+ // autoSave for the lastDeploy memo.
187
+ let resolved
188
+ try {
189
+ const deployYml = await loadDeployYml(siteDir)
190
+ // No --target on publish — it always targets Uniweb hosting; resolveTarget
191
+ // returns the uniweb default (fromFile:false) when there's no deploy.yml, so
192
+ // persistLastDeploy scaffolds the file as the "where it's deployed" record.
193
+ resolved = resolveTarget(deployYml, null)
194
+ } catch {
195
+ // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
196
+ resolved = { targetName: 'production', host: 'uniweb', config: {}, autoSave: 'lastDeploy', fromFile: false }
197
+ }
198
+ const autoSave = noSave ? 'off' : (resolved.autoSave || 'lastDeploy')
144
199
 
145
200
  if (dryRun) {
146
- say.info('Dry run — would publish the synced site (its current backend state):')
201
+ say.info('Dry run — would bring the foundation along, sync, and go live:')
147
202
  say.dim(`Backend : ${client.origin}`)
148
- say.dim(`Site uuid : ${uuid}`)
149
- say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
150
- if (languages) say.dim(`Languages : ${languages.join(', ')}`)
203
+ say.dim(`Runtime : ${runtimeVersion || '(unresolved — needs a backend or a site.yml runtime: pin)'}${runtimeVersion && !siteYml.runtime ? ' (highest installed)' : ''}`)
204
+ say.dim(`site_uuid : ${siteYml.$uuid || '(none the first push mints it)'}`)
205
+ const langs = languagesFromSiteYml(siteYml)
206
+ if (langs) say.dim(`Languages : ${langs.join(', ')}`)
207
+ await bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin: process.argv[1], dryRun: true })
208
+ await settlePaymentIfNeeded({ client, uuid: siteYml.$uuid || null, args, say, dryRun: true })
151
209
  return { exitCode: 0 }
152
210
  }
153
211
 
154
- // Pre-flight: `publish` makes the BACKEND's current state live NOT your local
155
- // files. If local content differs from the last push, surface it. Interactive
156
- // only; --yes / --force / --no-verify skip it, and a build error never blocks.
157
- if (!skipVerify && !isNonInteractive(args)) {
158
- let probe = null
212
+ // 1. Bring the foundation along release the local foundation if its code
213
+ // changed (or isn't registered). Never ship a site pointing at stale code.
214
+ let fnd
215
+ try {
216
+ fnd = await bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin: process.argv[1] })
217
+ } catch (err) {
218
+ say.err(`Foundation release failed: ${err.message}`)
219
+ say.dim('Fix the foundation, then re-run `uniweb publish`.')
220
+ return { exitCode: 1 }
221
+ }
222
+ if (!fnd.proceed) return { exitCode: 0 }
223
+
224
+ // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
225
+ // dist/data/*, dist/_search/*, dist/assets/*. Spawn the SAME CLI binary so
226
+ // the inner build can't resolve to a different installed version.
227
+ say.info('Building site…')
228
+ console.log('')
229
+ execSync(`node ${JSON.stringify(process.argv[1])} build --link`, { cwd: siteDir, stdio: 'inherit', env: process.env })
230
+ console.log('')
231
+
232
+ const distDir = join(siteDir, 'dist')
233
+ const contentPath = join(distDir, 'site-content.json')
234
+ if (!existsSync(contentPath)) {
235
+ say.err('Build did not produce dist/site-content.json')
236
+ return { exitCode: 1 }
237
+ }
238
+
239
+ // Non-local @std/registry Model schemas resolve through the backend (same as push).
240
+ const resolveModel = makeModelResolver({ client, offline: false })
241
+
242
+ // 3. Partition collections by schema presence (a first emit reads `schemaless`
243
+ // — collections with no data schema, delivered statically via the ball).
244
+ let probe
245
+ try {
246
+ probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
247
+ } catch (err) {
248
+ say.err(`Could not build the sync package: ${err.message}`)
249
+ return { exitCode: 1 }
250
+ }
251
+ const schemalessNames = (probe.schemaless || []).map((col) => col.name)
252
+ const localAssets = probe.localAssets || []
253
+
254
+ // 4. Assemble the static-data ball (schema-less data + search index) BEFORE
255
+ // uploading, since its records can carry local media too.
256
+ let ball = await assembleDataBall(distDir, schemalessNames)
257
+ const ballAssets = collectBallAssets(ball)
258
+
259
+ // 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
260
+ // the ref→serveUrl map; rewrite the entity content AND the ball with it.
261
+ let assetRewrite = null
262
+ const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
263
+ if (mediaRefs.length) {
264
+ say.info('Uploading media…')
159
265
  try {
160
- probe = await probeUnpushed(siteDir)
161
- } catch {
162
- probe = null
266
+ const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
267
+ onProgress: (m) => say.dim(` ${m}`),
268
+ warn: (m) => say.dim(`! ${m}`),
269
+ })
270
+ if (Object.keys(map).length) assetRewrite = map
271
+ if (ballAssets.length) ball = rewriteBallAssets(ball, map)
272
+ say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
273
+ } catch (err) {
274
+ say.err(`Media upload failed: ${err.message}`)
275
+ return { exitCode: 1 }
163
276
  }
164
- if (probe && probe.changed > 0) {
165
- say.warn(`You have ${probe.changed} unpushed local content change${probe.changed === 1 ? '' : 's'}.`)
166
- say.dim('`publish` makes the backend state live as-is; local edits are not included. Push first, or `uniweb deploy` to do both.')
167
- const proceed = await confirm('Publish the current backend state anyway?', true)
168
- if (!proceed) {
169
- say.info('Aborted run `uniweb push`, then `uniweb publish`.')
170
- return { exitCode: 0 }
171
- }
277
+ }
278
+
279
+ // 4c. Upload the (media-rewritten) ball its content-addressed serve URL.
280
+ let dataBundle
281
+ if (ball) {
282
+ say.info('Uploading data bundle…')
283
+ try {
284
+ dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
285
+ } catch (err) {
286
+ say.err(`Data bundle upload failed: ${err.message}`)
287
+ return { exitCode: 1 }
172
288
  }
289
+ say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
290
+ }
291
+
292
+ // 5. Push the site (content + folder) over the send-only-changed cache —
293
+ // the SAME two-lane submission `uniweb push` uses — stamping
294
+ // info.data_bundle and rewriting local media refs to backend serve URLs.
295
+ const priorHashes = readSyncCache(siteDir)
296
+ // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
297
+ // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
298
+ // Delivery is version-pinned end-to-end (the gateway serves a foundation only
299
+ // by a concrete version — collab framework-backend-5c3e), so pinning the
300
+ // released version on the wire is required when site.yml uses an unversioned
301
+ // local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
302
+ // is null → the site.yml ref is forwarded verbatim (already pinned).
303
+ const injectInfo = {
304
+ ...(dataBundle ? { data_bundle: dataBundle } : {}),
305
+ ...(fnd.ref ? { foundation: fnd.ref } : {}),
306
+ }
307
+ let pkg
308
+ try {
309
+ pkg = await emitSyncPackages(siteDir, {
310
+ ...(foundationDir ? { foundationDir } : {}),
311
+ resolveModel,
312
+ priorHashes,
313
+ ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
314
+ ...(assetRewrite ? { assetRewrite } : {}),
315
+ })
316
+ } catch (err) {
317
+ say.err(`Could not build the sync package: ${err.message}`)
318
+ return { exitCode: 1 }
319
+ }
320
+ for (const w of pkg.warnings) say.dim(`! ${w}`)
321
+ const report = {
322
+ info: (m) => say.info(m),
323
+ note: (m) => say.dim(m),
324
+ error: (m) => say.err(m),
325
+ dim: (s) => `${c.dim}${s}${c.reset}`,
326
+ }
327
+ const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
328
+ if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
329
+ const siteUuid = pushResult.boundSiteUuid
330
+ if (!siteUuid) {
331
+ say.err('Push did not yield a site uuid — cannot go live.')
332
+ return { exitCode: 1 }
333
+ }
334
+
335
+ // 6. Payment gate — the backend says whether go-live needs payment. Settles
336
+ // via a browser handoff to uniweb.app; degrades to "proceed" when the
337
+ // backend exposes no payment route. The draft is already synced, so a
338
+ // decline leaves a recoverable state (re-run after paying).
339
+ const pay = await settlePaymentIfNeeded({ client, uuid: siteUuid, args, say })
340
+ if (!pay.proceed) {
341
+ say.info('Site synced as a draft but not made live. Re-run `uniweb publish` once payment is complete.')
342
+ return { exitCode: 0 }
173
343
  }
174
344
 
175
- say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
176
- say.dim('Publishes the CURRENT backend state (incl. app-side edits) — run `uniweb push` first to include local edits.')
177
- let res
345
+ // 7. Go live — make the just-pushed composite live (its current backend state).
346
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
347
+ const languages = languagesFromContent(siteContent)
348
+ say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
349
+ let pubRes
178
350
  try {
179
- res = await client.publishSite(uuid, { runtimeVersion, ...(languages ? { languages } : {}) })
351
+ pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
180
352
  } catch (err) {
181
353
  say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
182
354
  say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
183
355
  return { exitCode: 1 }
184
356
  }
185
- if (!res.ok) {
186
- if (res.status === 404) {
187
- say.err(`Site ${uuid} not found on the backend (404).`)
188
- say.dim('Sync it first with `uniweb push`, or use `uniweb deploy` for a file-only site.')
189
- return { exitCode: 1 }
190
- }
191
- say.err(`Publish rejected: HTTP ${res.status} ${res.statusText}`)
192
- if (res.status === 401 || res.status === 403) {
357
+ if (!pubRes.ok) {
358
+ say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
359
+ if (pubRes.status === 401 || pubRes.status === 403) {
193
360
  say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
194
361
  }
195
- const body = await res.text().catch(() => '')
362
+ const body = await pubRes.text().catch(() => '')
196
363
  if (body) say.dim(body.slice(0, 800))
197
364
  return { exitCode: 1 }
198
365
  }
199
366
  let result
200
- try {
201
- result = await res.json()
202
- } catch {
203
- result = {}
204
- }
205
-
367
+ try { result = await pubRes.json() } catch { result = {} }
206
368
  const serveUrl = absolutizeServeUrl(client.origin, result.url)
369
+
370
+ // 8. Persist deploy.yml memory — a record of what went live (and so a re-run
371
+ // reuses the resolved target without re-asking). One identity:
372
+ // site.yml::$uuid. `released` records whether this publish shipped a new
373
+ // foundation version (the bring-along, §4).
374
+ // Record the ref that actually went live: the pinned `@scope/name@version`
375
+ // from the bring-along when present, else the site.yml ref verbatim.
376
+ const siteYmlRef = typeof siteYml.foundation === 'string' ? siteYml.foundation : siteYml.foundation?.ref || null
377
+ const recordedRef = fnd.ref || siteYmlRef
378
+ await persistLastDeploy(siteDir, {
379
+ targetName: resolved.targetName,
380
+ // First publish scaffolds deploy.yml with the backend recorded on the
381
+ // target, binding the site to where it went live (uniweb.app, or a B2B
382
+ // backend). resolveSiteBackend reads it back on later publishes.
383
+ targetConfig: resolved.fromFile ? null : { host: 'uniweb', backend: client.origin },
384
+ autoSave,
385
+ lastDeploy: {
386
+ at: new Date().toISOString(),
387
+ host: 'uniweb',
388
+ backend: client.origin,
389
+ siteUuid,
390
+ url: serveUrl,
391
+ foundation: { ...(recordedRef ? { ref: recordedRef } : {}), released: fnd.released },
392
+ runtime: runtimeVersion,
393
+ locales: Array.isArray(result.locales) ? result.locales : languages,
394
+ },
395
+ })
396
+
207
397
  console.log('')
208
- say.ok(`Published ${c.bold}${uuid}${c.reset}${result.status ? ` (${result.status})` : ''}`)
398
+ say.ok(`Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`)
209
399
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
210
400
  if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
211
401
  return { exitCode: 0 }
@@ -49,7 +49,7 @@ import {
49
49
  } from '@uniweb/build/uwx'
50
50
  import { makeModelResolver } from './push.js'
51
51
  import { BackendClient } from '../backend/client.js'
52
- import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
52
+ import { resolveSiteDir as defaultResolveSiteDir, resolveSiteBackend } from './deploy.js'
53
53
 
54
54
  const FOLDER_MODEL = '@uniweb/folder'
55
55
 
@@ -179,16 +179,18 @@ export async function pull(args = [], deps = {}) {
179
179
  const tokenFlag = flagValue(args, '--token')
180
180
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
181
181
  const noCollections = args.includes('--no-collections') || args.includes('--content-only')
182
+
183
+ const siteDir = await resolveSiteDir(args, 'pull')
184
+ const siteBackend = await resolveSiteBackend(siteDir)
182
185
  const client = new BackendClient({
183
- originFlag: flagValue(args, '--registry'),
186
+ originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
187
+ siteBackend,
184
188
  token: tokenFlag,
185
189
  getToken: deps.getToken,
186
190
  fetchImpl: deps.fetch,
187
191
  args,
188
192
  command: 'Pulling',
189
193
  })
190
-
191
- const siteDir = await resolveSiteDir(args, 'pull')
192
194
  // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
193
195
  // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
194
196
  const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
@@ -216,7 +218,7 @@ export async function pull(args = [], deps = {}) {
216
218
  res = await doRequest()
217
219
  } catch (err) {
218
220
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
219
- note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
221
+ note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
220
222
  return null
221
223
  }
222
224
  if (res.status === 404) {
@@ -36,7 +36,7 @@
36
36
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
37
37
  *
38
38
  * The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
39
- * send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb deploy`
39
+ * send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
40
40
  * (the composite path) reuses the exact same logic. This command owns flag parsing,
41
41
  * the emit, and the `-o`/`--dry-run` preview.
42
42
  */
@@ -45,7 +45,7 @@ import { writeFileSync } from 'node:fs'
45
45
  import { resolve } from 'node:path'
46
46
  import { emitSyncPackages } from '@uniweb/build/uwx'
47
47
  import { BackendClient } from '../backend/client.js'
48
- import { resolveSiteDir } from './deploy.js'
48
+ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
49
49
  import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
50
50
 
51
51
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -77,20 +77,22 @@ export async function push(args = []) {
77
77
  const asOrg = flagValue(args, '--as-org')
78
78
  const foundationDir = flagValue(args, '--foundation')
79
79
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
80
+
81
+ const siteDir = await resolveSiteDir(args, 'push')
82
+ const siteBackend = await resolveSiteBackend(siteDir)
80
83
  // One front door. The bearer is resolved lazily on first need (a non-local Model
81
84
  // read during the build, or the submit). Offline emit (--dry-run / -o) is fully
82
85
  // offline: it never submits, and its Model resolver never reads from the backend
83
86
  // (the `offline` flag below), so it never authenticates — even when a collection
84
87
  // references a Model the local foundation doesn't define.
85
88
  const client = new BackendClient({
86
- originFlag: flagValue(args, '--registry'),
89
+ originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
90
+ siteBackend,
87
91
  token: tokenFlag,
88
92
  args,
89
93
  command: 'Syncing',
90
94
  })
91
95
 
92
- const siteDir = await resolveSiteDir(args, 'push')
93
-
94
96
  // Build BOTH directional packages (the producer side). Each carries its own
95
97
  // `index` — the per-entity source-file map for back-fill, correlated by submission
96
98
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -147,7 +149,7 @@ export async function push(args = []) {
147
149
  }
148
150
 
149
151
  // Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
150
- // cache. Shared with `uniweb deploy` via ../backend/site-sync.js.
152
+ // cache. Shared with `uniweb publish` via ../backend/site-sync.js.
151
153
  const result = await pushSyncPackages({
152
154
  client,
153
155
  siteDir,
@@ -30,10 +30,11 @@
30
30
  * uniweb register --json Porcelain: ONE compact JSON line on stdout
31
31
  * ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
32
32
  * all human output to stderr — for scripted callers
33
- * uniweb register --registry <url> Override the submit endpoint (alias: --backend)
33
+ * uniweb register --backend <url> Override the backend origin (alias: --registry)
34
34
  * uniweb register --token <bearer> Submit with this bearer; skips `uniweb login`
35
35
  *
36
- * Endpoint resolution: --backend / --registry <url> > UNIWEB_REGISTER_URL > the local default.
36
+ * Endpoint resolution: --backend <url> (alias --registry) > UNIWEB_REGISTER_URL >
37
+ * the logged-in session origin > ~/.uniweb/config.json > the default (uniweb.app).
37
38
  * Auth (submit only): --token <bearer> > UNIWEB_TOKEN > `uniweb login` session.
38
39
  */
39
40
 
@@ -62,7 +63,7 @@ import { resolve, join } from 'node:path'
62
63
  import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
63
64
  import { classifyPackage, isSchemasPackage, collectStandaloneSchemas } from '@uniweb/build'
64
65
  import { readRegistryAuth } from '../utils/registry-auth.js'
65
- import { collectDistFiles } from '../utils/code-upload.js'
66
+ import { collectDistFiles, computeFoundationDigest } from '../utils/code-upload.js'
66
67
  import { deriveScope } from '../utils/registry-orgs.js'
67
68
  import { BackendClient } from '../backend/client.js'
68
69
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
@@ -309,12 +310,18 @@ async function runRegister(args = []) {
309
310
  }
310
311
  }
311
312
 
313
+ // Content digest (foundation path only) — the freshness fingerprint over what
314
+ // register ships (shipping-model.md §4.1). Rides in the foundation-schema
315
+ // entity's info.digest; the backend stores it opaque and returns it so
316
+ // publish/status can detect "code changed since release" with no local state.
317
+ const digest = standalone ? null : computeFoundationDigest(join(targetDir, 'dist'))
318
+
312
319
  const exporter = { tool: 'uniweb', version: cliVersion(), instance: 'build' }
313
320
  let doc
314
321
  try {
315
322
  doc = standalone
316
323
  ? buildSchemaOnlyPackage({ schemas, scope, exporter })
317
- : buildRegistryPackage({ schema, foundationDir: targetDir, scope, exporter })
324
+ : buildRegistryPackage({ schema, foundationDir: targetDir, scope, exporter, digest })
318
325
  } catch (err) {
319
326
  error(`Could not assemble the .uwx: ${err.message}`)
320
327
  return { exitCode: 2 }
@@ -330,6 +337,7 @@ async function runRegister(args = []) {
330
337
  }
331
338
  log(` ${colors.dim}data schemas ${standalone ? 'registered' : 'defined'}: ${defined.length ? defined.join(', ') : '(none)'}${colors.reset}`)
332
339
  if (scope) log(` ${colors.dim}scope: ${scope} (${scopeSource})${colors.reset}`)
340
+ if (digest) log(` ${colors.dim}digest: ${digest}${colors.reset}`)
333
341
 
334
342
  // Preview paths — no submit, no auth needed.
335
343
  if (output) {
@@ -469,7 +477,7 @@ async function runRegister(args = []) {
469
477
  ...doc.entities.filter((e) => e.model === '@uniweb/foundation-schema').map((e) => e.info?.name ?? e.name),
470
478
  ].filter(Boolean)
471
479
  const entities = names.map((name) => ({ name, ...(minted[name] || { uuid: null, version: null, unchanged: false }) }))
472
- emitJson({ ok: true, scope: scope || null, origin: client.origin, entities })
480
+ emitJson({ ok: true, scope: scope || null, origin: client.origin, digest: digest || null, entities })
473
481
  }
474
482
  return { exitCode: 0 }
475
483
  }
@@ -523,8 +523,9 @@ ${colors.bright}What rename extension does:${colors.reset}
523
523
  • Updates pnpm-workspace.yaml + package.json::workspaces.
524
524
 
525
525
  ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
526
- • Push to the registry. The publish id (package.json::uniweb.id) is
527
- independent. To rename on the registry too, run \`${prefix} publish --name <new>\`.
526
+ • Push to the registry. The registered id (package.json::uniweb.id) is
527
+ independent. To register under a new name, set it and run \`${prefix} register\`
528
+ (alias \`${prefix} release\`) — there is no rename flag.
528
529
 
529
530
  ${colors.bright}Examples:${colors.reset}
530
531
  ${prefix} rename foundation src marketing-src