uniweb 0.25.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.10.0",
45
- "@uniweb/kit": "^0.12.2",
46
- "@uniweb/runtime": "^0.12.0"
44
+ "@uniweb/semantic-parser": "^1.2.3",
45
+ "@uniweb/kit": "^0.12.3",
46
+ "@uniweb/runtime": "^0.12.1",
47
+ "@uniweb/core": "^0.10.1"
47
48
  },
48
49
  "peerDependencies": {
49
- "@uniweb/build": "^0.24.0",
50
- "@uniweb/semantic-parser": "^1.2.2",
51
- "@uniweb/content-reader": "^1.2.2"
50
+ "@uniweb/build": "^0.24.1",
51
+ "@uniweb/content-reader": "^1.2.3",
52
+ "@uniweb/semantic-parser": "^1.2.3"
52
53
  },
53
54
  "peerDependenciesMeta": {
54
55
  "@uniweb/build": {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Fetch the asset bytes a site project does not have locally.
3
+ *
4
+ * `pull` and `clone` bring content down; this brings the media with it, so a
5
+ * teammate who clones gets a project that renders instead of one full of images
6
+ * pointing at a host they may not even be able to reach.
7
+ *
8
+ * ## Where an asset lands, and why there are two answers
9
+ *
10
+ * - **The map knows it** (`assets.json`) — the file goes back to the path its
11
+ * author wrote. That is the whole reason the map is committed: a fresh clone
12
+ * restores `public/images/hero.png`, not a hash.
13
+ * - **The map does not know it** — this project has never held these bytes:
14
+ * authored in the app, or pushed from a machine whose map entry has not
15
+ * arrived. There is no local path to guess, so it lands at a **generic,
16
+ * content-addressed** one (`/assets/{id}.{ext}`) and the map gains the entry.
17
+ * From then on it is a known asset like any other.
18
+ *
19
+ * ## ⛔ The URL is READ from the content, never composed
20
+ *
21
+ * Identity rides beside the reference (`sync-package.js` stamps `assetId` next
22
+ * to the serve URL), so the address is already on the node. Composing one would
23
+ * mean holding the host's route layout — the coupling deleting `buildAssetUrl`
24
+ * removed from this CLI, and the reason `assets.json` stores no URL either. An
25
+ * id with no URL beside it is skipped, not guessed at.
26
+ *
27
+ * ## ⛔ A failed download is a WARNING, never a failed pull
28
+ *
29
+ * The content still carries the URL, so a site whose bytes did not arrive still
30
+ * renders — from the host, as it did before. Making the pull fail would turn a
31
+ * degraded outcome into no outcome, and the degraded one is genuinely usable.
32
+ */
33
+
34
+ import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
35
+ import { dirname, join } from 'node:path'
36
+ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
37
+ import { readAssetMap, updateAssetMap } from '@uniweb/build/uwx'
38
+
39
+ /**
40
+ * Collect every `{ id, ext, url }` an entity document references.
41
+ *
42
+ * Reads the same shape `rewriteEntityAssets` writes — identity on the object
43
+ * carrying the reference — so a ProseMirror image node's attrs and a section
44
+ * background's media object are both found by one walk.
45
+ */
46
+ export function collectAssetRefs(document) {
47
+ const found = new Map() // id → { id, ext, url }
48
+ const visit = (node) => {
49
+ if (Array.isArray(node)) return node.forEach(visit)
50
+ if (!node || typeof node !== 'object') return
51
+ for (const slot of ASSET_SLOTS) {
52
+ const id = typeof node[slot.id] === 'string' ? node[slot.id] : null
53
+ if (!id || found.has(id)) continue
54
+ const url = slot.urls
55
+ .map((k) => (typeof node[k] === 'string' ? node[k] : null))
56
+ .find(Boolean)
57
+ found.set(id, { id, ext: node[slot.ext] || '', url })
58
+ }
59
+ for (const v of Object.values(node)) visit(v)
60
+ }
61
+ visit(document)
62
+ return [...found.values()]
63
+ }
64
+
65
+ /** Where a site-root ref lives on disk. `resolveAssetPath` looks in public/ first. */
66
+ const diskPathFor = (siteDir, ref) => join(siteDir, 'public', ref)
67
+
68
+ /** The generic landing path for an asset this project has never held. */
69
+ export const genericRefFor = (id, ext) => `/assets/${id}${ext ? `.${ext}` : ''}`
70
+
71
+ /**
72
+ * Download the assets `document` references that are not already on disk.
73
+ *
74
+ * @param {object} opts
75
+ * @param {object} opts.document - the site-content document (not mutated)
76
+ * @param {string} opts.siteDir - the site root
77
+ * @param {string} opts.origin - backend origin, to resolve an origin-relative URL
78
+ * @param {typeof fetch} [opts.fetchImpl]
79
+ * @param {(m: string) => void} [opts.onProgress]
80
+ * @param {(m: string) => void} [opts.warn]
81
+ * @returns {Promise<{ downloaded: string[], present: string[], failed: string[], skipped: string[] }>}
82
+ */
83
+ export async function downloadMissingAssets({
84
+ document,
85
+ siteDir,
86
+ origin,
87
+ fetchImpl,
88
+ onProgress = () => {},
89
+ warn = () => {}
90
+ }) {
91
+ const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
92
+ const refs = collectAssetRefs(document)
93
+ const out = { downloaded: [], present: [], failed: [], skipped: [] }
94
+ if (!refs.length) return out
95
+
96
+ const map = readAssetMap(siteDir)
97
+ const byId = new Map()
98
+ for (const [ref, v] of Object.entries(map)) if (v?.id) byId.set(v.id, ref)
99
+
100
+ const learned = {}
101
+
102
+ for (const { id, ext, url } of refs) {
103
+ const known = byId.get(id)
104
+ const ref = known || genericRefFor(id, ext)
105
+ const disk = diskPathFor(siteDir, ref)
106
+
107
+ if (existsSync(disk)) {
108
+ out.present.push(ref)
109
+ // Still record identity for a generic ref we already hold but never mapped.
110
+ if (!known) learned[ref] = { id, ext }
111
+ continue
112
+ }
113
+ if (!url) {
114
+ // Identity with no address beside it. Composing one would mean holding the
115
+ // host's route layout; skipping is the honest answer.
116
+ warn(`asset ${id}: no URL in content to fetch from (skipped)`)
117
+ out.skipped.push(ref)
118
+ continue
119
+ }
120
+
121
+ try {
122
+ const res = await doFetch(new URL(url, origin).href)
123
+ if (!res.ok) {
124
+ warn(`asset ${id}: HTTP ${res.status} (kept the URL in content)`)
125
+ out.failed.push(ref)
126
+ continue
127
+ }
128
+ const bytes = Buffer.from(await res.arrayBuffer())
129
+ mkdirSync(dirname(disk), { recursive: true })
130
+ writeFileSync(disk, bytes)
131
+ onProgress(`↓ ${ref}`)
132
+ out.downloaded.push(ref)
133
+ if (!known) learned[ref] = { id, ext }
134
+ } catch (err) {
135
+ warn(`asset ${id}: ${err.message} (kept the URL in content)`)
136
+ out.failed.push(ref)
137
+ }
138
+ }
139
+
140
+ // Newly-landed assets become known ones, so the next pull restores them to
141
+ // this path rather than fetching them again to a second location.
142
+ if (Object.keys(learned).length) updateAssetMap(siteDir, learned)
143
+ return out
144
+ }
@@ -85,16 +85,20 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
85
85
  /**
86
86
  * The fallback capability doc when `GET /dev/config` is absent or unreachable
87
87
  * (an older backend, or no backend at all). Keeps the client non-breaking: the
88
- * bases mirror a self-serve dev backend, `assetBase` falls back to the historical
89
- * production CDN host so published-site asset resolution is unchanged, and
90
- * `runtime.installed` is empty so runtime resolution requires an explicit pin.
88
+ * bases mirror a self-serve dev backend, and `runtime.installed` is empty so
89
+ * runtime resolution requires an explicit pin.
91
90
  */
92
91
  export const DISCOVERY_DEFAULTS = {
93
- // No serve-root default. Serve locations are read from discovery or from an
94
- // upload plan's `serve_base`; nothing here reconstructs one, so a default
95
- // would be a route name with no consumer. (A `gatewayBase` entry lived here
96
- // unread until 2026-07-29.)
97
- assetBase: 'https://assets.uniweb.app/',
92
+ // No serve-root default, and no `assetBase`. Serve locations are read from
93
+ // discovery or from a per-response field (an upload plan's `serve_base`, an
94
+ // asset entry's `serve_url`); nothing here reconstructs one, so a default is a
95
+ // route name with no consumer. (A `gatewayBase` entry lived here unread until
96
+ // 2026-07-29.)
97
+ //
98
+ // `assetBase: 'https://assets.uniweb.app/'` sat here until 2026-08-17 — one
99
+ // production host, hardcoded, applied to EVERY deployment the CLI can be
100
+ // pointed at. It was only ever read to compose an asset URL, which the plan
101
+ // already returns as `serve_url`; both the reader and the composer are gone.
98
102
  auth: { loginPath: '/dev/auth/login', required: true },
99
103
  delivery: { deploy: true, publish: true, broker: 'self-serve' },
100
104
  assets: { supported: false },
@@ -220,7 +224,6 @@ export class BackendClient {
220
224
  * lifetime; a missing route or any transport/parse error falls back to
221
225
  * DISCOVERY_DEFAULTS (non-breaking — an older backend still works). Lets the
222
226
  * CLI hardcode nothing about a backend but its origin and discover the rest:
223
- * `assetBase` (the asset root — relative ⇒ relative-to-origin),
224
227
  * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?),
225
228
  * `runtime.installed` (the default-runtime source replacing the old /runtime/latest).
226
229
  * @returns {Promise<object>}
@@ -509,7 +512,7 @@ export class BackendClient {
509
512
  }
510
513
 
511
514
  /**
512
- * ASSUMED endpoint (not built yet — kb/framework/build/payment-handoff-plan.md).
515
+ * ASSUMED endpoint (not built yet).
513
516
  * GET /dev/site/{uuid}/can-go-live — the pre-go-live payment gate:
514
517
  * { ok: true } // already paid → proceed
515
518
  * { payment_required: true, checkout_url, wait_token? } // open the URL, settle, retry
@@ -533,8 +536,8 @@ export class BackendClient {
533
536
  /**
534
537
  * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
535
538
  * content-addressed store. Thin pass-through to utils/asset-upload.js with this
536
- * client's origin + token; returns the `localUrl → { id, ext }` rewrite map the
537
- * deploy step turns into durable `{assetBase}dist/{id}/base.{ext}` serve URLs.
539
+ * client's origin + token; returns the `localUrl → { id, ext, serveUrl }` rewrite
540
+ * map the deploy step reads `serveUrl` from verbatim (never composing one).
538
541
  * @param {object} opts - { distDir, files?, onProgress? }
539
542
  */
540
543
  async uploadSiteAssets(opts) {
@@ -9,13 +9,15 @@
9
9
  * single upload entry — `uploadSiteAssets` PUTs `bytes` when present, else reads a
10
10
  * `diskPath` (its media path). Content-addressed like every asset: identical ball →
11
11
  * same id → a re-deploy of unchanged data is a cheap no-op PUT.
12
+ *
13
+ * The returned URL is the plan entry's `serve_url`, read verbatim. See the note at
14
+ * the return for why the origin-relative form is safe to store.
12
15
  */
13
16
 
14
17
  import { createHash } from 'node:crypto'
15
- import { buildAssetUrl } from '../utils/asset-upload.js'
16
18
 
17
19
  /**
18
- * @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
20
+ * @param {object} client - BackendClient (origin + uploadSiteAssets)
19
21
  * @param {{ data: object, search: object }} ball - the assembled data ball
20
22
  * @param {{ onProgress?: (m: string) => void }} [opts]
21
23
  * @returns {Promise<string>} the content-addressed serve URL (→ `info.data_bundle`)
@@ -50,6 +52,34 @@ export async function uploadDataBundle(
50
52
  const entry = result.assetsByLocalUrl[localUrl]
51
53
  if (!entry) throw new Error('data-bundle upload returned no asset id')
52
54
 
53
- const config = await client.discover()
54
- return buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
55
+ // The backend's canonical serve URL, READ — never composed. Same rule and the
56
+ // same code path as `site-media.js`; until 2026-08-17 this composed from a
57
+ // discovered `assetBase` unconditionally, which made it the one place a
58
+ // discovery failure could bake the historical production CDN host into content
59
+ // we push.
60
+ //
61
+ // Storing it verbatim is safe even though `serve_url` is ORIGIN-RELATIVE in the
62
+ // backend's `direct` mode — the only mode any deployment has ever run.
63
+ // `info.data_bundle` is never fetched over HTTP: the backend resolves it to a
64
+ // blob-store key, discarding everything before the final `dist/`, so absolute
65
+ // and relative forms resolve to the same key (pinned both ways on their side).
66
+ //
67
+ // ⚠️ That argument is scoped to a serve URL CONTAINING `dist/`, and the scope is
68
+ // load-bearing rather than incidental: the key is recovered by splitting on that
69
+ // segment, so a serve URL without one cannot be resolved to a key at all — the
70
+ // failure is on the READING side, and this push looks entirely successful.
71
+ // Reported by the backend 2026-08-17 as a defect on their side, not yet fixed;
72
+ // not verified here, and not ours to fix. It is unreached only because no
73
+ // deployment yet mints URLs of the other shape. ⇒ We keep reading `serve_url`
74
+ // verbatim — composing one would be the worse answer, and it is the very coupling
75
+ // deleted above. What we must NOT do is infer from "this has always worked" that
76
+ // any serve URL round-trips; that holds for the shape, not for the field.
77
+ //
78
+ // Absent is an error, not a cue to invent a location: an unaddressable bundle
79
+ // must stop the publish, never ship a URL nobody claimed. (Confirmed with the
80
+ // backend 2026-08-17; `serve_url` is contractually on every plan entry.)
81
+ if (!entry.serveUrl)
82
+ throw new Error('data-bundle upload returned no serve_url')
83
+
84
+ return entry.serveUrl
55
85
  }
@@ -7,7 +7,7 @@
7
7
  * `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
8
8
  * file under the site's `public/` (or `assets/`). A ref whose file is missing is
9
9
  * skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
10
- * `serve_url` when present, else reconstructed from `id`+`assetBase` (the dev fallback).
10
+ * `serve_url`, read verbatim origin-relative forms included; nothing here composes one.
11
11
  * Content-addressed like every asset: identical bytes → same id → a re-deploy of
12
12
  * unchanged media is a cheap no-op PUT (the lane's `present` skip-list).
13
13
  */
@@ -16,18 +16,18 @@ import { createHash } from 'node:crypto'
16
16
  import { existsSync, readFileSync } from 'node:fs'
17
17
  import { basename } from 'node:path'
18
18
  import { resolveAssetPath } from '@uniweb/build/site'
19
- import { buildAssetUrl } from '../utils/asset-upload.js'
20
19
  import { contentTypeFor } from '../utils/code-upload.js'
21
20
 
22
21
  /**
23
- * @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
22
+ * @param {object} client - BackendClient (uploadSiteAssets). No `discover` this
23
+ * lane stopped consulting the capability doc when `assetBase` was removed.
24
24
  * @param {string} siteDir - the site root (site-root refs resolve under public/)
25
25
  * @param {string[]} refs - site-root local asset refs (`/images/x.png`)
26
26
  * @param {{ siteUuid?: string|null, onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
27
27
  * `siteUuid` is the owner the uploaded bytes are charged to. Callers create the
28
28
  * site before uploading precisely so this is set — an unowned upload is charged
29
29
  * and cannot be freed, because freeing means deleting the owning entity.
30
- * @returns {Promise<{ map: Record<string,string>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
30
+ * @returns {Promise<{ map: Record<string,string>, ids: Record<string,{id:string,ext:string}>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
31
31
  * `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
32
32
  * kinds are reported SEPARATELY because callers must treat them differently:
33
33
  * `missing` is a ref with no file under the site — an authoring mistake, already
@@ -41,7 +41,7 @@ export async function uploadSiteMedia(
41
41
  refs,
42
42
  { siteUuid = null, onProgress, warn } = {}
43
43
  ) {
44
- if (!refs?.length) return { map: {}, missing: [], failed: [] }
44
+ if (!refs?.length) return { map: {}, ids: {}, missing: [], failed: [] }
45
45
 
46
46
  const files = []
47
47
  const missing = []
@@ -62,23 +62,46 @@ export async function uploadSiteMedia(
62
62
  diskPath: resolved
63
63
  })
64
64
  }
65
- if (!files.length) return { map: {}, missing, failed: [] }
65
+ if (!files.length) return { map: {}, ids: {}, missing, failed: [] }
66
66
 
67
67
  const result = await client.uploadSiteAssets({ files, siteUuid, onProgress })
68
- const failed = result.failed || []
68
+ const failed = [...(result.failed || [])]
69
69
  for (const f of failed)
70
70
  warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
71
71
 
72
- const config = await client.discover()
73
72
  const map = {}
73
+ // The identity half, for `assets.json`. The plan returns an authoritative
74
+ // `id`+`ext` for every entry INCLUDING a `present: true` dedup skip, so a
75
+ // re-push of unchanged media still records identity without moving bytes —
76
+ // which is what makes the committed map cheap to keep accurate.
77
+ const ids = {}
74
78
  for (const ref of refs) {
75
79
  const entry = result.assetsByLocalUrl[ref]
76
- if (entry)
77
- map[ref] =
78
- entry.serveUrl ||
79
- buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
80
+ if (!entry) continue
81
+ if (entry.id) ids[ref] = { id: entry.id, ext: entry.ext || '' }
82
+ // The backend's canonical serve URL, READ — never composed. An entry without
83
+ // one is an asset we cannot address, and inventing a location for it is the
84
+ // exact failure this lane exists to avoid: a guessed host is SILENTLY wrong,
85
+ // where a missing one is visibly missing. So it joins `failed` and publish
86
+ // refuses, rather than shipping content pointing somewhere nobody claimed.
87
+ //
88
+ // `serve_url` is part of the asset-plan contract: on EVERY entry, both
89
+ // lanes, including entries reported as already `present`. Reconstruction
90
+ // from a discovered `assetBase` was removed once the backend covered that
91
+ // with a test of its own; it was the last hardcoded cross-deployment
92
+ // constant in the CLI. (Confirmed 2026-08-17.)
93
+ if (!entry.serveUrl) {
94
+ warn?.(`local-media: ${ref} — the asset plan returned no serve_url`)
95
+ failed.push({
96
+ path: ref,
97
+ status: 0,
98
+ detail: 'asset plan entry carried no serve_url'
99
+ })
100
+ continue
101
+ }
102
+ map[ref] = entry.serveUrl
80
103
  }
81
- return { map, missing, failed }
104
+ return { map, ids, missing, failed }
82
105
  }
83
106
 
84
107
  // ─── Asset-plan refusals ──────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
2
2
  * uniweb clone <site-uuid> — materialize a backend site as a local file project.
3
3
  *
4
4
  * The "git clone" of the site-content remote model (see
5
- * kb/framework/plans/site-content-remote-model.md): the backend is the remote, a
5
+ * the site-content remote model): the backend is the remote, a
6
6
  * file project is a working clone. `clone` is the create-side sibling of
7
7
  * `uniweb pull`/`uniweb push` — it bootstraps a brand-new project from a site that
8
8
  * already lives in the backend (typically authored in the visual app).
@@ -81,6 +81,7 @@ import {
81
81
  } from '../backend/site-sync.js'
82
82
  import { uploadDataBundle } from '../backend/data-bundle.js'
83
83
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
84
+ import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
84
85
  import {
85
86
  bringFoundationAlong,
86
87
  bringExtensionsAlong
@@ -273,10 +274,32 @@ export async function publish(args = []) {
273
274
  // X, and the next publish would restate a *different* version the producer
274
275
  // computed locally, silently undoing it.
275
276
  //
276
- // Silence is not a request to change the runtime, so the backend resolves it:
277
- // the site's CURRENT resolved runtime UNIWEBD_DEFAULT_RUNTIME → (self-serve)
278
- // highest installed 400. That keeps a propagated site where the walk put it,
279
- // and it is the authority's answer rather than ours.
277
+ // The deeper reason, upstream of ownership: a link-mode site is CODELESS. It
278
+ // ships no JS, so it has nothing that binds to a runtime version and cannot
279
+ // break when the runtime moves. Asking it to name one is not a hard question,
280
+ // it is a malformed one. The party that binds is the FOUNDATION, whose build
281
+ // externalizes react / react-dom / jsx-runtime / @uniweb/core — which is why
282
+ // the compatibility floor rides on the foundation (`info.runtime`, set by
283
+ // `register`) and not here. See
284
+ // the site/foundation/runtime model, § "who gets to say
285
+ // whether a site accepts a newer runtime".
286
+ //
287
+ // Silence is therefore not a request to change the runtime, and the backend
288
+ // resolves it: an explicit pin → the site's CURRENT resolved runtime →
289
+ // UNIWEBD_DEFAULT_RUNTIME → (self-serve) highest installed.
290
+ //
291
+ // ⚠️ THAT LAST SENTENCE IS A CLAIM ABOUT ANOTHER LANE, and it was FALSE when it
292
+ // was first written here — `/dev/site/publish` required `?runtime=` with no
293
+ // fallback at all, so an unpinned publish 400'd and no scaffolded project could
294
+ // publish. It read as true because the `/api` lane did have the fallback, and
295
+ // the backend's own doc justified the omission with "the CLI always pins from
296
+ // site.yml" — which was equally false, since no template ships a `runtime:` key.
297
+ // Two complementary assumptions, each load-bearing for the other lane, neither
298
+ // ever checked. Found by driving the CLI against a live backend, which is the
299
+ // only vantage point from which either assumption is visible.
300
+ // ✅ Now the shipped contract, agreed with the backend and verified end to end
301
+ // with the workaround removed (2026-08-16). Re-verify against the backend
302
+ // rather than trusting this comment if it starts mattering again.
280
303
  //
281
304
  // ⚠️ Do NOT reintroduce a local fallback. Sending our own guess when the site
282
305
  // did not ask is what makes an unpinned republish regress. (The pinned path is
@@ -505,11 +528,12 @@ export async function publish(args = []) {
505
528
  // 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
506
529
  // the ref→serveUrl map; rewrite the entity content AND the ball with it.
507
530
  let assetRewrite = null
531
+ let assetIds = null
508
532
  const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
509
533
  if (mediaRefs.length) {
510
534
  say.info('Uploading media…')
511
535
  try {
512
- const { map, failed } = await uploadSiteMedia(
536
+ const { map, ids, failed } = await uploadSiteMedia(
513
537
  client,
514
538
  siteDir,
515
539
  mediaRefs,
@@ -529,6 +553,15 @@ export async function publish(args = []) {
529
553
  return { exitCode: 1 }
530
554
  }
531
555
  if (Object.keys(map).length) assetRewrite = map
556
+ if (Object.keys(ids).length) assetIds = ids
557
+ // Identity into the COMMITTED map — see backend/asset-map.js. Merge, not
558
+ // replace: this publish carries only the refs its content touched.
559
+ const rec = updateAssetMap(siteDir, ids)
560
+ if (rec.written) {
561
+ say.dim(
562
+ `${ASSET_MAP_FILE} : ${rec.added.length} added, ${rec.changed.length} changed — commit it`
563
+ )
564
+ }
532
565
  if (ballAssets.length) ball = rewriteBallAssets(ball, map)
533
566
  say.dim(
534
567
  `Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`
@@ -615,7 +648,8 @@ export async function publish(args = []) {
615
648
  ...(Object.keys(ext.pins).length
616
649
  ? { injectExtensions: ext.pins }
617
650
  : {}),
618
- ...(assetRewrite ? { assetRewrite } : {})
651
+ ...(assetRewrite ? { assetRewrite } : {}),
652
+ ...(assetIds ? { assetIds } : {})
619
653
  })
620
654
  } catch (err) {
621
655
  say.err(`Could not build the sync package: ${err.message}`)
@@ -68,6 +68,7 @@ import { createHash } from 'node:crypto'
68
68
  import { createInterface } from 'node:readline/promises'
69
69
  import { join, dirname, relative } from 'node:path'
70
70
  import yaml from 'js-yaml'
71
+ import { downloadMissingAssets } from '../backend/asset-download.js'
71
72
  import {
72
73
  siteContentDocumentToProject,
73
74
  collectionsToProject,
@@ -127,6 +128,29 @@ function flagValue(args, name) {
127
128
  return null
128
129
  }
129
130
 
131
+ // Whether this pull should fetch the asset bytes it does not have.
132
+ //
133
+ // Two levers, and they are deliberately not the same kind of thing:
134
+ // - `site.yml::assets.download: false` — the PROJECT's standing choice. "We do
135
+ // not keep the bytes here" is usually a property of a repo, not of a run, and
136
+ // a property of a repo belongs in a file every clone reads.
137
+ // - `--no-assets` — the RUN's override, for CI and one-off checkouts, where the
138
+ // project's answer is right and this invocation is the exception.
139
+ //
140
+ // The flag can only turn fetching OFF. A project that declared `download: false`
141
+ // meant it, and a flag that could silently re-enable it would make the declared
142
+ // setting advisory.
143
+ function shouldFetchAssets(args, siteDir) {
144
+ if (args.includes('--no-assets')) return false
145
+ try {
146
+ const cfg = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
147
+ if (cfg?.assets?.download === false) return false
148
+ } catch {
149
+ /* no site.yml, or unreadable — fetching is the default */
150
+ }
151
+ return true
152
+ }
153
+
130
154
  // Read a top-level `$uuid:` scalar from a YAML file, or null.
131
155
  function readYamlUuid(filePath) {
132
156
  try {
@@ -494,6 +518,7 @@ export async function pull(args = [], deps = {}) {
494
518
  const mergeMode = args.includes('--merge')
495
519
 
496
520
  const siteDir = await resolveSiteDir(args, 'pull')
521
+ const fetchAssets = shouldFetchAssets(args, siteDir)
497
522
 
498
523
  // Don't overwrite work that isn't saved anywhere. Pull reconciles the working
499
524
  // tree to the backend — it rewrites section bodies from the fetched document and
@@ -640,6 +665,33 @@ export async function pull(args = [], deps = {}) {
640
665
  // Per-item identity for the next push. Without it the backend reads our
641
666
  // records as new and re-mints every page and section row.
642
667
  writeItemUuids(siteDir, collectUnitUuids(siteDoc))
668
+ // Bring the media down BEFORE projecting: a newly-landed asset gains a map
669
+ // entry, and the projection reads that map to put authored paths back. Run
670
+ // after, and this pull's new assets would project as URLs and only restore
671
+ // on the NEXT pull — a lag nobody would attribute to ordering.
672
+ //
673
+ // Declining is a project-level choice (`site.yml::assets.download: false`)
674
+ // with a per-run override (`--no-assets`), because "we do not want the
675
+ // bytes" is usually a property of the project and only sometimes of the
676
+ // invocation (CI). Either way the content keeps its URL and still renders.
677
+ if (fetchAssets) {
678
+ const dl = await downloadMissingAssets({
679
+ document: siteDoc,
680
+ siteDir,
681
+ origin: client.origin,
682
+ onProgress: (m) => note(` ${m}`),
683
+ warn: (m) => note(`! ${m}`)
684
+ })
685
+ if (dl.downloaded.length) {
686
+ note(`Assets : ${dl.downloaded.length} downloaded`)
687
+ }
688
+ if (dl.failed.length) {
689
+ note(
690
+ `Assets : ${dl.failed.length} could not be fetched — content keeps their URL`
691
+ )
692
+ }
693
+ }
694
+
643
695
  const report = siteContentDocumentToProject({
644
696
  document: siteDoc,
645
697
  siteRoot: siteDir,
@@ -64,6 +64,7 @@ import { writeFileSync } from 'node:fs'
64
64
  import { resolve } from 'node:path'
65
65
  import { emitSyncPackages } from '@uniweb/build/uwx'
66
66
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
67
+ import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
67
68
  import { BackendClient } from '../backend/client.js'
68
69
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
69
70
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
@@ -246,6 +247,7 @@ export async function push(args = [], deps = {}) {
246
247
  // stores hashes of the REWRITTEN content, so the emit compared against it must
247
248
  // rewrite too, or every entity reads as changed forever.
248
249
  let assetRewrite = null
250
+ let assetIds = null
249
251
  if (!output && !dryRun) {
250
252
  let mediaRefs = []
251
253
  try {
@@ -276,7 +278,7 @@ export async function push(args = [], deps = {}) {
276
278
  }
277
279
  info('Uploading media…')
278
280
  try {
279
- const { map, failed } = await uploadSiteMedia(
281
+ const { map, ids, failed } = await uploadSiteMedia(
280
282
  client,
281
283
  siteDir,
282
284
  mediaRefs,
@@ -299,9 +301,18 @@ export async function push(args = [], deps = {}) {
299
301
  return { exitCode: 1 }
300
302
  }
301
303
  if (Object.keys(map).length) assetRewrite = map
304
+ if (Object.keys(ids).length) assetIds = ids
302
305
  note(
303
306
  `${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`
304
307
  )
308
+ // Record identity in the COMMITTED map. Merge, never replace: this push
309
+ // carries only the refs its content touched.
310
+ const rec = updateAssetMap(siteDir, ids)
311
+ if (rec.written) {
312
+ note(
313
+ `${ASSET_MAP_FILE}: ${rec.added.length} added, ${rec.changed.length} changed — commit it`
314
+ )
315
+ }
305
316
  } catch (err) {
306
317
  // Typed plan refusals get their own account. Note the storage one must not
307
318
  // be phrased from what moved — see describeAssetRefusal's rule 1; a push can
@@ -341,7 +352,8 @@ export async function push(args = [], deps = {}) {
341
352
  baseVersions: readBaseVersions(siteDir),
342
353
  itemBaseVersions: readItemBaseVersions(siteDir)
343
354
  }),
344
- ...(assetRewrite ? { assetRewrite } : {})
355
+ ...(assetRewrite ? { assetRewrite } : {}),
356
+ ...(assetIds ? { assetIds } : {})
345
357
  })
346
358
  } catch (err) {
347
359
  error(`Could not build the sync package: ${err.message}`)
@@ -57,7 +57,13 @@
57
57
  // (legacy deploy-runtime had `--propagate` too). Implement once the backend
58
58
  // has a version-update/propagation policy; until then every register is silent.
59
59
 
60
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
60
+ import {
61
+ existsSync,
62
+ readFileSync,
63
+ writeFileSync,
64
+ readdirSync,
65
+ statSync
66
+ } from 'node:fs'
61
67
  import { execSync } from 'node:child_process'
62
68
  import { resolve, join } from 'node:path'
63
69
  import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
@@ -204,18 +210,82 @@ async function resolveFoundationDir(args) {
204
210
  process.exit(1)
205
211
  }
206
212
 
213
+ // Directories that are never foundation source. `dist` is the output we compare
214
+ // against; the rest are tooling. Dotted entries are skipped wholesale.
215
+ const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
216
+
217
+ /**
218
+ * The newest mtime under `dir`, skipping build output and tooling. `0` when the
219
+ * tree is unreadable — which reads as "no source is newer", i.e. it degrades to
220
+ * the pre-existing behaviour rather than forcing a build on an fs error.
221
+ *
222
+ * @param {string} dir
223
+ * @returns {number} epoch ms
224
+ */
225
+ function newestSourceMtime(dir) {
226
+ let newest = 0
227
+ const walk = (d) => {
228
+ let entries
229
+ try {
230
+ entries = readdirSync(d, { withFileTypes: true })
231
+ } catch {
232
+ return
233
+ }
234
+ for (const e of entries) {
235
+ if (e.name.startsWith('.') || NON_SOURCE_DIRS.has(e.name)) continue
236
+ const p = join(d, e.name)
237
+ if (e.isDirectory()) {
238
+ walk(p)
239
+ continue
240
+ }
241
+ try {
242
+ const m = statSync(p).mtimeMs
243
+ if (m > newest) newest = m
244
+ } catch {
245
+ /* unreadable file — ignore */
246
+ }
247
+ }
248
+ }
249
+ walk(dir)
250
+ return newest
251
+ }
252
+
207
253
  /**
208
254
  * Does the foundation's `dist/` need a (re)build before we can register it?
209
255
  *
210
- * Mirrors `uniweb publish`'s build-if-stale so `register` is a full drop-in
211
- * for the foundation-publish flow. Two staleness signals:
256
+ * Three staleness signals:
212
257
  * - MISSING: no `dist/entry.js` (or the legacy `dist/foundation.js`), or no
213
258
  * `dist/meta/schema.json` — nothing built yet.
214
- * - STALE: the version baked into `dist/meta/schema.json::_self.version`
259
+ * - STALE VERSION: the version baked into `dist/meta/schema.json::_self.version`
215
260
  * differs from `package.json::version` — a version bump without a rebuild,
216
261
  * so the artifact encodes the OLD version while the register intends the
217
262
  * NEW one (we'd otherwise submit a schema whose version disagrees with the
218
263
  * code we deliver).
264
+ * - STALE SOURCE: a source file is newer than `dist/entry.js`.
265
+ *
266
+ * ⛔ **The source signal was missing until 2026-08-16, and its absence shipped the
267
+ * PREVIOUS bundle silently.** Edit a component's rendered output, re-run
268
+ * `register` with a `dist/` present, and the version signal says fresh (nothing
269
+ * bumped), the artifact signal says fresh (files exist) — so the old bytes upload
270
+ * and the content digest legitimately does not move. The developer's freshness
271
+ * indicator reports "unchanged" and it is telling the truth about an input that
272
+ * never changed. Found by end-to-end testing against a live backend, which is
273
+ * the only place the two halves meet: a unit test on this predicate agrees with
274
+ * it, and the digest agrees with the bytes.
275
+ *
276
+ * ⚠️ **This header used to claim it "mirrors `uniweb publish`'s build-if-stale".
277
+ * It does not, and that sentence is why nobody looked.** `publish` does not do
278
+ * build-if-stale at all — `backend/foundation-bring-along.js` builds
279
+ * UNCONDITIONALLY before digesting, precisely so the digest reflects current
280
+ * source. So publish was already correct and register was not, while a docblock
281
+ * asserted they matched.
282
+ *
283
+ * ⛔ **And do NOT "fix" this by building unconditionally to match publish.**
284
+ * `publish` builds and then shells out to THIS command
285
+ * (`foundation-bring-along.js` → `execFileSync(cliBin, ['register', …])`), so an
286
+ * unconditional build here makes every publish build twice. The mtime signal is
287
+ * what keeps both paths right: publish's fresh `dist/` is newer than its source,
288
+ * so this correctly skips.
219
289
  *
220
290
  * Returns `{ needs: false }` or `{ needs: true, reason }`.
221
291
  *
@@ -262,6 +332,26 @@ export function foundationNeedsBuild(targetDir) {
262
332
  } catch {
263
333
  return { needs: true, reason: 'dist/meta/schema.json could not be parsed' }
264
334
  }
335
+ // STALE SOURCE — the signal whose absence shipped the previous bundle. Compared
336
+ // against the built entry rather than the schema: the schema is emitted by a
337
+ // later plugin hook, so it is always the newer of the two and using it would
338
+ // widen the window in which an edit reads as already-built.
339
+ //
340
+ // Ties count as fresh (`>`, not `>=`). A build writes its output after reading
341
+ // its input, so equal mtimes mean a same-millisecond build, not a missed edit —
342
+ // and on a fresh clone, where whole trees share a timestamp, `>=` would rebuild
343
+ // every foundation once for nothing.
344
+ try {
345
+ const entryPath = existsSync(join(distDir, 'entry.js'))
346
+ ? join(distDir, 'entry.js')
347
+ : join(distDir, 'foundation.js')
348
+ const builtAt = statSync(entryPath).mtimeMs
349
+ if (newestSourceMtime(targetDir) > builtAt)
350
+ return { needs: true, reason: 'a source file is newer than dist/entry.js' }
351
+ } catch {
352
+ // Can't compare — leave the other signals to decide rather than forcing a
353
+ // build on an fs error.
354
+ }
265
355
  return { needs: false }
266
356
  }
267
357
 
@@ -364,7 +454,17 @@ async function runRegister(args = []) {
364
454
  try {
365
455
  execSync('npx uniweb build --target foundation', {
366
456
  cwd: targetDir,
367
- stdio: 'inherit'
457
+ // ⛔ `inherit` wires the DELEGATE's stdout to ours, so under --json the
458
+ // builder's progress lands on stdout ahead of the JSON line and
459
+ // `JSON.parse(stdout)` throws — on exactly the cold path a fresh
460
+ // checkout always takes. register's own output is already redirected to
461
+ // stderr (see `log` above); this child is the one stream it did not own,
462
+ // so --json was porcelain only when a build was NOT needed.
463
+ //
464
+ // fd 2 is our stderr: progress stays visible, stdout stays parseable.
465
+ // Found by a harness that had to work around it by taking the last
466
+ // JSON line off stdout.
467
+ stdio: jsonMode ? ['inherit', 2, 'inherit'] : 'inherit'
368
468
  })
369
469
  } catch (err) {
370
470
  error(`Build failed: ${err.message}`)
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-16T13:24:21.340Z",
3
+ "generatedAt": "2026-08-18T13:23:47.915Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.24.0",
6
+ "version": "0.24.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -14,11 +14,12 @@
14
14
  "@uniweb/schemas",
15
15
  "@uniweb/schemas",
16
16
  "@uniweb/semantic-parser",
17
+ "@uniweb/semantic-parser",
17
18
  "@uniweb/theming"
18
19
  ]
19
20
  },
20
21
  "@uniweb/content-reader": {
21
- "version": "1.2.2",
22
+ "version": "1.2.3",
22
23
  "path": "framework/content-reader",
23
24
  "deps": []
24
25
  },
@@ -28,7 +29,7 @@
28
29
  "deps": []
29
30
  },
30
31
  "@uniweb/core": {
31
- "version": "0.10.0",
32
+ "version": "0.10.1",
32
33
  "path": "framework/core",
33
34
  "deps": [
34
35
  "@uniweb/semantic-parser",
@@ -41,12 +42,14 @@
41
42
  "deps": []
42
43
  },
43
44
  "@uniweb/icons": {
44
- "version": "0.4.0",
45
+ "version": "0.4.1",
45
46
  "path": "framework/icons",
46
- "deps": []
47
+ "deps": [
48
+ "@uniweb/core"
49
+ ]
47
50
  },
48
51
  "@uniweb/kit": {
49
- "version": "0.12.2",
52
+ "version": "0.12.3",
50
53
  "path": "framework/kit",
51
54
  "deps": [
52
55
  "@uniweb/core",
@@ -73,7 +76,7 @@
73
76
  ]
74
77
  },
75
78
  "@uniweb/runtime": {
76
- "version": "0.12.0",
79
+ "version": "0.12.1",
77
80
  "path": "framework/runtime",
78
81
  "deps": [
79
82
  "@uniweb/core",
@@ -96,7 +99,7 @@
96
99
  "deps": []
97
100
  },
98
101
  "@uniweb/semantic-parser": {
99
- "version": "1.2.2",
102
+ "version": "1.2.3",
100
103
  "path": "framework/semantic-parser",
101
104
  "deps": []
102
105
  },
@@ -77,8 +77,8 @@ export function collectSiteAssets(distDir) {
77
77
  *
78
78
  * `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
79
79
  * plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
80
- * verbatim; `buildAssetUrl(origin, assetBase, id, ext)` reconstructs the same
81
- * string and stays as a fallback for an older backend that omits `serve_url`.
80
+ * verbatim origin-relative forms included. Nothing composes one: see the note at
81
+ * the foot of this file for why the reconstruction helper was removed.
82
82
  *
83
83
  * Skip-list (content-addressed dedup): a plan entry with `present: true` is
84
84
  * already in the global store — we don't re-PUT it, but we DO record its id+ext
@@ -231,14 +231,17 @@ export async function uploadSiteAssets({
231
231
  return { mode, uploaded, skipped, failed, assetsByLocalUrl }
232
232
  }
233
233
 
234
- // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
235
- // prepend the backend origin; absolute (a CDN host) → used verbatim. Shape:
236
- // {assetBase}dist/{id}/base.{ext} — basename literally `base`, {ext} the source
237
- // extension the plan echoed. The root itself is never assumed: it is whatever
238
- // discovery returned.
239
- export function buildAssetUrl(origin, assetBase, id, ext) {
240
- const base = /^https?:\/\//.test(assetBase)
241
- ? assetBase
242
- : `${origin}${assetBase}`
243
- return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
244
- }
234
+ // `buildAssetUrl(origin, assetBase, id, ext)` was removed 2026-08-17. Do not
235
+ // bring it back.
236
+ //
237
+ // It composed `{assetBase}dist/{id}/base.{ext}` i.e. it encoded the BACKEND's
238
+ // path layout inside a published CLI, on a release cadence the backend cannot
239
+ // move, defaulting to one hardcoded production host for every deployment. Read
240
+ // the plan entry's `serve_url` instead. It is part of the asset-plan contract:
241
+ // present on EVERY entry, on both the direct and presigned lanes, including
242
+ // entries the plan reports as already `present` — the backend covers that with a
243
+ // test of its own (confirmed 2026-08-17).
244
+ //
245
+ // An entry without one is UNADDRESSABLE — fail, never guess. A composed location
246
+ // is silently wrong wherever the deployment does not match the guess; a missing
247
+ // one is visibly missing.
@@ -69,13 +69,14 @@ const VERBS = {
69
69
  ],
70
70
  pull: [
71
71
  '--backend', '--content-only', '--dry-run', '--force', '--merge',
72
+ '--no-assets',
72
73
  '--no-collections', '--no-delete', '--no-prune', '--registry', '--token',
73
74
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
74
75
  '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
75
76
  ],
76
77
  clone: [
77
- '--backend', '--content-only', '--no-collections', '--path', '--project',
78
- '--registry', '--token', '--org', '--as-org'
78
+ '--backend', '--content-only', '--no-assets', '--no-collections', '--path',
79
+ '--project', '--registry', '--token', '--org', '--as-org'
79
80
  ],
80
81
  register: [
81
82
  '--backend', '--dry-run', '--json', '--output', '-o', '--registry',
@@ -5,7 +5,7 @@
5
5
  * A site's `package.json`, its `file:` dependency on the foundation, and its
6
6
  * `node_modules` are not part of the site as an artifact; they are the project
7
7
  * shape the CLI hangs tooling on (see the three-ingredient model,
8
- * kb/framework/architecture/site-foundation-runtime-model.md, Part 1). Treating
8
+ * the site/foundation/runtime model). Treating
9
9
  * that scaffolding as part of the site is where most of the confusion in this
10
10
  * area comes from, so: nothing here says anything about what a foundation *is*,
11
11
  * how it is distributed, or how it reaches a host.
@@ -8,10 +8,6 @@ dist/
8
8
  # Generated entry (regenerated by `uniweb build`; never committed)
9
9
  _entry.generated.js
10
10
 
11
- # Local mock-cloud state (when running `uniweb publish --local` or
12
- # the unicloud dev server inside this workspace)
13
- .unicloud/
14
-
15
11
  # Environment
16
12
  .env
17
13
  .env.local