uniweb 0.12.33 → 0.12.35

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.
@@ -34,17 +34,23 @@
34
34
  * Backend: via BackendClient (the content + folder sync lanes). Origin from
35
35
  * --registry > UNIWEB_REGISTER_URL > the local default.
36
36
  * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
37
+ *
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`
40
+ * (the composite path) reuses the exact same logic. This command owns flag parsing,
41
+ * the emit, and the `-o`/`--dry-run` preview.
37
42
  */
38
43
 
39
- import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
40
- import { resolve, join, dirname } from 'node:path'
41
- import {
42
- emitSyncPackages,
43
- backfillEntityUuids,
44
- writeSiteEntityUuid,
45
- } from '@uniweb/build/uwx'
44
+ import { writeFileSync } from 'node:fs'
45
+ import { resolve } from 'node:path'
46
+ import { emitSyncPackages } from '@uniweb/build/uwx'
46
47
  import { BackendClient } from '../backend/client.js'
47
48
  import { resolveSiteDir } from './deploy.js'
49
+ import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
50
+
51
+ // Re-exported for downstream importers (pull.js, push.test.js) that read these
52
+ // helpers from this module — their canonical home is now ../backend/site-sync.js.
53
+ export { extractMintedSiteUuid, makeModelResolver } from '../backend/site-sync.js'
48
54
 
49
55
  const colors = {
50
56
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
@@ -64,101 +70,6 @@ function flagValue(args, name) {
64
70
  return null
65
71
  }
66
72
 
67
- // Pull the finalized entities out of the restore response. The backend returns
68
- // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each
69
- // entry carries its position in the SUBMITTED sequence (`index`, the correlation
70
- // key — `$id` is not echoed), the minted entity `uuid`, a `changed` flag, and the
71
- // full `document` (verbatim stored content with every `$uuid` filled in). A couple
72
- // of shapes are tolerated; only entries with a valid `index` + `uuid` are usable.
73
- function extractFinalized(payload) {
74
- const list = Array.isArray(payload?.report?.finalized)
75
- ? payload.report.finalized
76
- : Array.isArray(payload?.finalized)
77
- ? payload.finalized
78
- : Array.isArray(payload)
79
- ? payload
80
- : null
81
- if (!list) return null
82
- return list
83
- .map((d) => ({
84
- index: d?.index,
85
- uuid: d?.uuid ?? d?.document?.$uuid ?? null,
86
- changed: d?.changed,
87
- document: d?.document ?? null,
88
- }))
89
- .filter((e) => Number.isInteger(e.index) && e.uuid)
90
- }
91
-
92
- // Pull the minted site-content uuid out of a CREATE
93
- // response. The exact shape is an open backend item — tolerant of a bare
94
- // `{ siteContentUuid }` / `{ $uuid }` / `{ uuid }`, or the same `report.finalized[]`
95
- // envelope the update/folder lanes return (the site entity is submitted alone, so its
96
- // minted uuid is the first finalized entry). Returns null if none is present. (Single
97
- // adjust-point to pin at the first live CREATE.)
98
- export function extractMintedSiteUuid(payload) {
99
- if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
100
- if (typeof payload?.$uuid === 'string') return payload.$uuid
101
- if (typeof payload?.uuid === 'string') return payload.uuid
102
- const finalized = extractFinalized(payload)
103
- if (finalized && finalized.length) {
104
- const site = finalized.find((f) => f.index === 0) || finalized[0]
105
- return site?.uuid ?? null
106
- }
107
- return null
108
- }
109
-
110
- // One-line summary from the authoritative per-entity `changed` flag (`false` = a
111
- // true no-op). Falls back silently when the backend omits it.
112
- function changedSummary(finalized) {
113
- const changed = finalized.filter((f) => f.changed === true).length
114
- const unchanged = finalized.filter((f) => f.changed === false).length
115
- const parts = []
116
- if (changed) parts.push(`${changed} changed`)
117
- if (unchanged) parts.push(`${unchanged} unchanged`)
118
- return parts.length ? parts.join(', ') : null
119
- }
120
-
121
- // Resolve a Model NOT defined by the local foundation by reading its declaration
122
- // (the `@uniweb/data-schema` form) from the backend via the client. Cached per run;
123
- // HTTP 404 → null (the emitter then says "register it first"). The bearer is acquired
124
- // lazily by the client, so a fully-local sync never authenticates.
125
- //
126
- // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
127
- // touching the backend — an offline emit must never authenticate. The collections
128
- // emitter then soft-skips a convention-defaulted schema with a warning ("not synced")
129
- // and still emits the site-content lane; an EXPLICIT non-local schema surfaces as a
130
- // clear "could not be resolved" error rather than an auth prompt.
131
- export function makeModelResolver({ client, offline = false }) {
132
- const cache = new Map()
133
- return async (modelName) => {
134
- if (cache.has(modelName)) return cache.get(modelName)
135
- const decl = offline ? null : await client.readDataSchema(modelName)
136
- cache.set(modelName, decl)
137
- return decl
138
- }
139
- }
140
-
141
- // "Send only changed" cache: content hashes from the last successful sync, keyed
142
- // `<model> <id>`. Gitignored, per-clone, deletable (a deleted cache just means one
143
- // full re-sync, which the backend then no-ops). NOT identity — the minted `$uuid`
144
- // lives in the source files; this is a pure wire-efficiency cache.
145
- function syncCachePath(siteDir) {
146
- return join(siteDir, '.uniweb', 'sync-cache.json')
147
- }
148
- function readSyncCache(siteDir) {
149
- try {
150
- const obj = JSON.parse(readFileSync(syncCachePath(siteDir), 'utf8'))
151
- return obj && typeof obj.hashes === 'object' && obj.hashes ? obj.hashes : {}
152
- } catch {
153
- return {} // missing / unreadable → treat everything as changed
154
- }
155
- }
156
- function writeSyncCache(siteDir, hashes) {
157
- const p = syncCachePath(siteDir)
158
- mkdirSync(dirname(p), { recursive: true })
159
- writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
160
- }
161
-
162
73
  export async function push(args = []) {
163
74
  const dryRun = args.includes('--dry-run')
164
75
  const output = flagValue(args, '-o') || flagValue(args, '--output')
@@ -197,7 +108,7 @@ export async function push(args = []) {
197
108
  error(`Could not build the sync package: ${err.message}`)
198
109
  return { exitCode: 2 }
199
110
  }
200
- const { siteContent, collections, siteContentUuid, warnings, hashes, skipped } = pkg
111
+ const { siteContent, collections, siteContentUuid, warnings, skipped } = pkg
201
112
  log('')
202
113
  for (const w of warnings) note(`! ${w}`)
203
114
 
@@ -235,116 +146,19 @@ export async function push(args = []) {
235
146
  return { exitCode: 0 }
236
147
  }
237
148
 
238
- const wrote = []
239
- let finalizedTotal = 0
240
-
241
- // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
242
- // returning the client's Response promise (so the "Pushing …" line prints before the
243
- // request fires). The client carries `collision=force` (last-push-wins) + the optional
244
- // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
245
- // (already reported).
246
- const postLane = async (label, doRequest) => {
247
- info(`Pushing ${label} to ${colors.dim}${client.origin}${colors.reset} …`)
248
- let res
249
- try {
250
- res = await doRequest()
251
- } catch (err) {
252
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
253
- note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
254
- return null
255
- }
256
- if (!res.ok) {
257
- error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
258
- if (res.status === 401 || res.status === 403) {
259
- note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
260
- }
261
- const body = await res.text().catch(() => '')
262
- if (body) note(body.slice(0, 800))
263
- return null
264
- }
265
- try {
266
- return await res.json()
267
- } catch (err) {
268
- error(`Could not parse the ${label} response as JSON: ${err.message}`)
269
- return null
270
- }
271
- }
272
-
273
- // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
274
- // finalized list (for record back-fill + the changed summary). Returns the finalized
275
- // array, or null on failure (already reported).
276
- const pushLane = async (label, doRequest) => {
277
- const payload = await postLane(label, doRequest)
278
- if (payload === null) return null
279
- const finalized = extractFinalized(payload)
280
- if (!finalized) {
281
- error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
282
- note(JSON.stringify(payload).slice(0, 800))
283
- return null
284
- }
285
- const summary = changedSummary(finalized)
286
- if (summary) note(`${label}: ${summary}`)
287
- return finalized
288
- }
289
-
290
- // Lane 1 — site-content (the site is born here; it must exist before its folder). A
291
- // known site uuid → UPDATE by uuid; none → CREATE (the backend mints + adopts the
292
- // site and returns its uuid, which we record into site.yml). `boundSiteUuid` carries
293
- // the minted/known uuid forward to key the folder push.
294
- let boundSiteUuid = siteContentUuid
295
- if (siteContent) {
296
- if (siteContentUuid) {
297
- const finalized = await pushLane(
298
- 'site-content',
299
- () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg })
300
- )
301
- if (!finalized) return { exitCode: 1 }
302
- finalizedTotal += finalized.length
303
- } else {
304
- const payload = await postLane(
305
- 'site-content',
306
- () => client.createSiteContent(siteContent.buffer, { asOrg })
307
- )
308
- if (payload === null) return { exitCode: 1 }
309
- const minted = extractMintedSiteUuid(payload)
310
- if (!minted) {
311
- error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
312
- note(JSON.stringify(payload).slice(0, 800))
313
- return { exitCode: 1 }
314
- }
315
- writeSiteEntityUuid(siteDir, minted)
316
- boundSiteUuid = minted
317
- wrote.push('recorded site $uuid in site.yml')
318
- finalizedTotal += extractFinalized(payload)?.length ?? 1
319
- }
320
- }
321
-
322
- // Lane 2 — collections (the @uniweb/folder + the records it references), keyed by the
323
- // site-content uuid. On a brand-new site the backend creates the folder on this first
324
- // push. Records round-trip their own $uuid (back-filled into source files); the folder
325
- // itself has no uuid (the backend owns it).
326
- if (collections) {
327
- if (!boundSiteUuid) {
328
- error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
329
- return { exitCode: 1 }
330
- }
331
- const finalized = await pushLane(
332
- 'collections',
333
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
334
- )
335
- if (!finalized) return { exitCode: 1 }
336
- const bf = backfillEntityUuids({ index: collections.index, finalized })
337
- for (const w of bf.warnings) note(`! ${w}`)
338
- for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
339
- if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
340
- finalizedTotal += finalized.length
341
- }
342
-
343
- // Persist the full content-hash map so the next push skips unchanged entities.
344
- writeSyncCache(siteDir, hashes)
149
+ // 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.
151
+ const result = await pushSyncPackages({
152
+ client,
153
+ siteDir,
154
+ pkg,
155
+ asOrg,
156
+ report: { info, note, error, dim: (s) => `${colors.dim}${s}${colors.reset}` },
157
+ })
158
+ if (result.exitCode !== 0) return { exitCode: result.exitCode }
345
159
  success(
346
- `Pushed ${finalizedTotal} entit${finalizedTotal === 1 ? 'y' : 'ies'}` +
347
- (wrote.length ? ` — ${wrote.join(', ')}` : '')
160
+ `Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
161
+ (result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
348
162
  )
349
163
  return { exitCode: 0 }
350
164
  }
@@ -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>
@@ -0,0 +1,174 @@
1
+ /**
2
+ * uniweb status — show how a site's local files compare to the Uniweb backend:
3
+ * its sync identity, unpushed content changes, and the foundation it references.
4
+ *
5
+ * LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
6
+ * resolver and diffs them against the send-only-changed cache (the same diff
7
+ * `uniweb push` runs) — no auth, no backend round-trip.
8
+ *
9
+ * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
+ * - whether the synced draft differs from what's live (publish needed), and
11
+ * - whether a newer foundation version is registered than the site pins.
12
+ * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
13
+ * the backend exposes them, `--remote` degrades silently to the local view.
14
+ *
15
+ * Usage:
16
+ * uniweb status Sync identity + unpushed content + foundation ref (local)
17
+ * uniweb status --remote Also: draft-vs-live + a newer-registered-foundation check
18
+ * uniweb status --json One JSON line (adds a `remote` object under --remote)
19
+ *
20
+ * Run from a site, or a workspace with one site.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import yaml from 'js-yaml'
26
+
27
+ import { resolveSiteDir } from './deploy.js'
28
+ import { probeUnpushed } from '../backend/site-sync.js'
29
+ import { BackendClient } from '../backend/client.js'
30
+ import { readFlagValue } from '../utils/args.js'
31
+
32
+ const c = {
33
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
34
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m',
35
+ }
36
+ const say = {
37
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
38
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
39
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
40
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
41
+ }
42
+
43
+ function readSiteYml(siteDir) {
44
+ const p = join(siteDir, 'site.yml')
45
+ if (!existsSync(p)) return {}
46
+ try {
47
+ return yaml.load(readFileSync(p, 'utf8')) || {}
48
+ } catch {
49
+ return {}
50
+ }
51
+ }
52
+
53
+ function foundationRef(siteYml) {
54
+ const f = siteYml.foundation
55
+ if (!f) return null
56
+ return typeof f === 'string' ? f : f.ref || null
57
+ }
58
+
59
+ // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
60
+ // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
61
+ function splitFoundationRef(fnd) {
62
+ if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
63
+ const at = fnd.lastIndexOf('@')
64
+ return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
65
+ }
66
+
67
+ export async function status(args = []) {
68
+ const jsonMode = args.includes('--json')
69
+ const remote = args.includes('--remote')
70
+ const siteDir = await resolveSiteDir(args, 'status')
71
+ const siteYml = readSiteYml(siteDir)
72
+ const uuid = siteYml.$uuid || null
73
+ const fnd = foundationRef(siteYml)
74
+ const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
75
+
76
+ // Local content diff — builds the sync packages, never authenticates.
77
+ let probe = null
78
+ let probeErr = null
79
+ try {
80
+ probe = await probeUnpushed(siteDir)
81
+ } catch (err) {
82
+ probeErr = err.message
83
+ }
84
+
85
+ // Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
86
+ // on 404 / any failure, so a backend without the endpoints just shows local.
87
+ let site = null
88
+ let fdnLatest = null
89
+ if (remote) {
90
+ try {
91
+ const client = new BackendClient({
92
+ originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
93
+ token: readFlagValue(args, '--token') || undefined,
94
+ args,
95
+ command: 'Status',
96
+ })
97
+ if (uuid) site = await client.siteStatus(uuid)
98
+ if (fndScope) fdnLatest = await client.readFoundationLatest(fndScope)
99
+ } catch {
100
+ // degrade silently
101
+ }
102
+ }
103
+
104
+ if (jsonMode) {
105
+ console.log(
106
+ JSON.stringify({
107
+ synced: Boolean(uuid),
108
+ uuid,
109
+ foundation: fnd,
110
+ changed: probe ? probe.changed : null,
111
+ unchanged: probe ? probe.unchanged : null,
112
+ ...(probeErr ? { error: probeErr } : {}),
113
+ ...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null } } : {}),
114
+ })
115
+ )
116
+ return { exitCode: 0 }
117
+ }
118
+
119
+ console.log('')
120
+
121
+ // Sync identity
122
+ if (uuid) {
123
+ say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
124
+ } else {
125
+ say.warn('Not synced — this site has never been pushed to a backend.')
126
+ say.dim('Run `uniweb push` to create it, or `uniweb deploy` to ship it in one step.')
127
+ }
128
+
129
+ // Content
130
+ if (probeErr) {
131
+ say.warn(`Couldn't compute content changes: ${probeErr}`)
132
+ say.dim('A build error or an unresolved data Model can block the offline diff.')
133
+ } else if (!uuid) {
134
+ const n = probe.changed
135
+ say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
136
+ } else if (probe.changed === 0) {
137
+ say.ok('Content is in sync with the last push.')
138
+ } else {
139
+ const n = probe.changed
140
+ say.info(
141
+ `${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
142
+ (probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
143
+ '.'
144
+ )
145
+ say.dim('Run `uniweb push` to sync, then `uniweb publish` to go live (or `uniweb deploy` for both).')
146
+ }
147
+
148
+ // Foundation
149
+ if (fnd) say.dim(`Foundation: ${fnd}`)
150
+
151
+ // Remote signals
152
+ if (remote) {
153
+ if (site) {
154
+ if (site.draft_dirty) {
155
+ say.info('Synced draft has changes not yet live — run `uniweb publish` to go live.')
156
+ } else if (site.published) {
157
+ say.ok('Live with the latest synced content.')
158
+ } else {
159
+ say.info('Synced but not published yet — run `uniweb publish` to go live.')
160
+ }
161
+ }
162
+ if (fdnLatest?.latest_version && fndVersion && fdnLatest.latest_version !== fndVersion) {
163
+ say.info(`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`)
164
+ }
165
+ if (!site && !fdnLatest) {
166
+ say.dim('(No remote signals — the backend may not expose them yet.)')
167
+ }
168
+ }
169
+
170
+ console.log('')
171
+ return { exitCode: 0 }
172
+ }
173
+
174
+ export default status
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-18T03:42:23.549Z",
3
+ "generatedAt": "2026-06-24T03:15:27.555Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.16",
6
+ "version": "0.14.18",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.22",
102
+ "version": "0.4.24",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
package/src/index.js CHANGED
@@ -627,6 +627,13 @@ async function main() {
627
627
  process.exit(result?.exitCode ?? 0)
628
628
  }
629
629
 
630
+ // Handle status command (dynamic import — offline emit via @uniweb/build)
631
+ if (command === 'status') {
632
+ const { status } = await importProjectCommand('./commands/status.js')
633
+ const result = await status(args.slice(1))
634
+ process.exit(result?.exitCode ?? 0)
635
+ }
636
+
630
637
  // Handle clone command (global — bootstraps a new project from a backend site;
631
638
  // STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
632
639
  // project-local CLI that doesn't exist yet. clone.js avoids any static
@@ -1470,6 +1477,7 @@ ${colors.bright}Commands:${colors.reset}
1470
1477
  runtime register Register an @uniweb/runtime version to the backend (@std only)
1471
1478
  push Push a site's content to the backend
1472
1479
  pull Pull a site's content from the backend
1480
+ status Show a site's sync state (unpushed content, foundation)
1473
1481
  inspect <path> Inspect parsed content shape of a markdown file or folder
1474
1482
  docs Generate component documentation
1475
1483
  doctor Diagnose project configuration issues
@@ -73,10 +73,15 @@ export function collectSiteAssets(distDir) {
73
73
 
74
74
  /**
75
75
  * Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
76
- * (`localUrl → { id, ext }`) — populated for files that uploaded successfully OR
77
- * that the plan reports already `present`, so a partial failure never injects a
78
- * broken serve URL into content. Throws only on a plan-level failure; per-file
79
- * PUT failures surface in `failed`.
76
+ * (`localUrl → { id, ext, serveUrl }`) — populated for files that uploaded
77
+ * successfully OR that the plan reports already `present`, so a partial failure
78
+ * never injects a broken serve URL into content. Throws only on a plan-level
79
+ * failure; per-file PUT failures surface in `failed`.
80
+ *
81
+ * `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
82
+ * plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
83
+ * verbatim; `buildAssetUrl(origin, assetBase, id, ext)` reconstructs the same
84
+ * string and stays as a fallback for an older backend that omits `serve_url`.
80
85
  *
81
86
  * Skip-list (content-addressed dedup): a plan entry with `present: true` is
82
87
  * already in the global store — we don't re-PUT it, but we DO record its id+ext
@@ -89,7 +94,7 @@ export function collectSiteAssets(distDir) {
89
94
  * @param {string} opts.distDir - the site's built dist/ directory
90
95
  * @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
91
96
  * @param {(msg: string) => void} [opts.onProgress]
92
- * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string }> }>}
97
+ * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
93
98
  */
94
99
  export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
95
100
  const list = files || collectSiteAssets(distDir)
@@ -130,7 +135,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
130
135
  // ⇒ false (older backend) → falls through to the upload path below.
131
136
  if (up.present) {
132
137
  skipped.push(src.path)
133
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
138
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
134
139
  continue
135
140
  }
136
141
 
@@ -144,7 +149,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
144
149
  try {
145
150
  // The plan's url may be origin-relative (direct mode → uniwebd) or
146
151
  // absolute (presigned → storage); new URL() resolves both.
147
- putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: readFileSync(src.diskPath) })
152
+ putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
148
153
  } catch (err) {
149
154
  failed.push({ path: src.path, status: 0, detail: err.message })
150
155
  continue
@@ -152,7 +157,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
152
157
  if (putRes.ok) {
153
158
  uploaded.push(src.path)
154
159
  // Authoritative id + ext from the plan; mapped only on a successful PUT.
155
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
160
+ assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
156
161
  } else {
157
162
  failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
158
163
  }
@@ -160,3 +165,12 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
160
165
 
161
166
  return { mode, uploaded, skipped, failed, assetsByLocalUrl }
162
167
  }
168
+
169
+ // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
170
+ // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
171
+ // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
172
+ // `base`, {ext} the source extension the plan echoed.
173
+ export function buildAssetUrl(origin, assetBase, id, ext) {
174
+ const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
175
+ return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
176
+ }