uniweb 0.12.34 → 0.12.36

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,41 +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
 
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'
51
+
36
52
  import { BackendClient } from '../backend/client.js'
37
- import { resolveSiteDir } from './deploy.js'
53
+ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
38
54
  import { readFlagValue } from '../utils/args.js'
55
+ import { isNonInteractive } from '../utils/interactive.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'
39
61
 
40
62
  const c = {
41
63
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -49,8 +71,20 @@ const say = {
49
71
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
50
72
  }
51
73
 
74
+ // Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
75
+ async function confirm(question, defaultYes = false) {
76
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
77
+ try {
78
+ const a = (await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase()
79
+ if (!a) return defaultYes
80
+ return a === 'y' || a === 'yes'
81
+ } finally {
82
+ rl.close()
83
+ }
84
+ }
85
+
52
86
  // Highest installed runtime from the backend's /dev/config list (numeric-aware
53
- // sort). Mirrors deploy.js's resolver. Null when the list is empty.
87
+ // sort). Null when the list is empty.
54
88
  function pickHighestRuntime(installed) {
55
89
  if (!Array.isArray(installed) || installed.length === 0) return null
56
90
  return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
@@ -63,10 +97,26 @@ function absolutizeServeUrl(origin, url) {
63
97
  return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
64
98
  }
65
99
 
66
- // Locale set from site.yml — source/default first, then declared locales.
67
- // Tolerant of the shapes site.yml uses (i18n.locales, languages[]). Null when
68
- // single-locale, so the body omits `languages` and the backend defaults.
69
- 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) {
70
120
  const def = siteYml.defaultLanguage || siteYml.lang || 'en'
71
121
  const locales = siteYml.i18n?.locales || siteYml.languages
72
122
  if (!Array.isArray(locales) || locales.length === 0) return null
@@ -74,100 +124,278 @@ function extractLanguages(siteYml) {
74
124
  return [def, ...norm.filter((l) => l !== def)]
75
125
  }
76
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
+
77
140
  export async function publish(args = []) {
78
141
  const dryRun = args.includes('--dry-run')
79
- 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
80
145
 
81
- // The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
82
- // No uuid the site was never synced; publish has nothing to make live.
83
- const siteYmlPath = join(siteDir, 'site.yml')
84
- let siteYml = {}
85
- if (existsSync(siteYmlPath)) {
86
- try {
87
- siteYml = yaml.load(await readFile(siteYmlPath, 'utf8')) || {}
88
- } catch {
89
- siteYml = {}
90
- }
91
- }
92
- const uuid = siteYml.$uuid
93
- if (!uuid) {
94
- say.err('This site has no $uuid in site.yml — it was never synced to the backend.')
95
- say.dim('Run `uniweb push` first (publish makes the synced site live), or use `uniweb deploy` for a file-only site.')
96
- return { exitCode: 1 }
97
- }
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)
98
151
 
99
152
  const client = new BackendClient({
100
153
  originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
154
+ siteBackend,
101
155
  token: readFlagValue(args, '--token') || undefined,
102
156
  args,
103
157
  command: 'Publishing',
104
158
  })
105
159
 
106
- // Discover + resolve the runtime exactly like deploy: explicit site.yml::runtime,
107
- // 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.
108
162
  const config = await client.discover()
109
163
  if (config?.delivery && config.delivery.publish === false) {
110
164
  say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
111
165
  return { exitCode: 1 }
112
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.
113
171
  const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
114
172
  if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
115
173
  say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
116
174
  say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
117
- return { exitCode: 1 }
175
+ if (!dryRun) return { exitCode: 1 }
118
176
  }
119
177
  const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
120
- if (!runtimeVersion) {
178
+ if (!runtimeVersion && !dryRun) {
121
179
  say.err('Could not resolve a runtime version.')
122
180
  say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
123
181
  return { exitCode: 1 }
124
182
  }
125
183
 
126
- 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')
127
199
 
128
200
  if (dryRun) {
129
- 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:')
130
202
  say.dim(`Backend : ${client.origin}`)
131
- say.dim(`Site uuid : ${uuid}`)
132
- say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
133
- 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 })
134
209
  return { exitCode: 0 }
135
210
  }
136
211
 
137
- say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
138
- say.dim('Publishes the CURRENT backend state (incl. app-side edits) run `uniweb push` first to include local edits.')
139
- let res
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
140
215
  try {
141
- res = await client.publishSite(uuid, { runtimeVersion, ...(languages ? { languages } : {}) })
216
+ fnd = await bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin: process.argv[1] })
142
217
  } catch (err) {
143
- say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
144
- say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
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}`)
145
249
  return { exitCode: 1 }
146
250
  }
147
- if (!res.ok) {
148
- if (res.status === 404) {
149
- say.err(`Site ${uuid} not found on the backend (404).`)
150
- say.dim('Sync it first with `uniweb push`, or use `uniweb deploy` for a file-only site.')
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…')
265
+ try {
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 }
276
+ }
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}`)
151
287
  return { exitCode: 1 }
152
288
  }
153
- say.err(`Publish rejected: HTTP ${res.status} ${res.statusText}`)
154
- if (res.status === 401 || res.status === 403) {
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 }
343
+ }
344
+
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
350
+ try {
351
+ pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
352
+ } catch (err) {
353
+ say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
354
+ say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
355
+ return { exitCode: 1 }
356
+ }
357
+ if (!pubRes.ok) {
358
+ say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
359
+ if (pubRes.status === 401 || pubRes.status === 403) {
155
360
  say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
156
361
  }
157
- const body = await res.text().catch(() => '')
362
+ const body = await pubRes.text().catch(() => '')
158
363
  if (body) say.dim(body.slice(0, 800))
159
364
  return { exitCode: 1 }
160
365
  }
161
366
  let result
162
- try {
163
- result = await res.json()
164
- } catch {
165
- result = {}
166
- }
167
-
367
+ try { result = await pubRes.json() } catch { result = {} }
168
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
+
169
397
  console.log('')
170
- 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})` : ''}`)
171
399
  if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
172
400
  if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
173
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
  }
@@ -30,9 +30,11 @@
30
30
  * (target name already taken, target not found, folder collision,
31
31
  * type mismatch) we bail with a clear message and no partial state.
32
32
  *
33
- * Out of scope: registry side. The publish id (package.json::uniweb.id)
34
- * is independent of the workspace name and stays untouched. Users who
35
- * want to also rename on the registry run `uniweb publish --name <new>`.
33
+ * Out of scope: registry side. The registered id (the scoped
34
+ * `package.json::name` / `uniweb.id`) is independent of the workspace name and
35
+ * stays untouched. There is no registry-rename flag a registered version is
36
+ * immutable; to change a foundation's registered identity, register it under
37
+ * the new name (consuming sites repoint their `foundation:` ref).
36
38
  *
37
39
  * Usage:
38
40
  * uniweb rename foundation <old> <new>
@@ -521,8 +523,9 @@ ${colors.bright}What rename extension does:${colors.reset}
521
523
  • Updates pnpm-workspace.yaml + package.json::workspaces.
522
524
 
523
525
  ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
524
- • Push to the registry. The publish id (package.json::uniweb.id) is
525
- 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.
526
529
 
527
530
  ${colors.bright}Examples:${colors.reset}
528
531
  ${prefix} rename foundation src marketing-src