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 +8 -7
- package/src/backend/asset-download.js +144 -0
- package/src/backend/client.js +15 -12
- package/src/backend/data-bundle.js +34 -4
- package/src/backend/site-media.js +36 -13
- package/src/commands/clone.js +1 -1
- package/src/commands/publish.js +40 -6
- package/src/commands/pull.js +52 -0
- package/src/commands/push.js +14 -2
- package/src/commands/register.js +105 -5
- package/src/framework-index.json +12 -9
- package/src/utils/asset-upload.js +16 -13
- package/src/utils/flag-guard.js +3 -2
- package/src/utils/install-integrity.js +1 -1
- package/templates/workspace/_gitignore +0 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.25.
|
|
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/
|
|
45
|
-
"@uniweb/kit": "^0.12.
|
|
46
|
-
"@uniweb/runtime": "^0.12.
|
|
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.
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
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
|
+
}
|
package/src/backend/client.js
CHANGED
|
@@ -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, `
|
|
89
|
-
*
|
|
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
|
|
94
|
-
// upload plan's `serve_base
|
|
95
|
-
//
|
|
96
|
-
// unread until
|
|
97
|
-
|
|
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
|
|
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
|
|
537
|
-
* deploy step
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 ──────────────────────────────────────────────────────
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
-
*
|
|
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).
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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}`)
|
package/src/commands/pull.js
CHANGED
|
@@ -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,
|
package/src/commands/push.js
CHANGED
|
@@ -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}`)
|
package/src/commands/register.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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}`)
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-18T13:23:47.915Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.24.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
81
|
-
*
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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.
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -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-
|
|
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
|
-
*
|
|
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
|