uniweb 0.24.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 +24 -7
- package/src/commands/refresh.js +16 -0
- package/src/commands/register.js +105 -5
- package/src/commands/sync.js +16 -2
- package/src/framework-index.json +13 -10
- package/src/index.js +52 -0
- package/src/utils/asset-upload.js +16 -13
- package/src/utils/flag-guard.js +38 -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.
|
|
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'
|
|
@@ -113,13 +114,18 @@ function flagValue(args, name) {
|
|
|
113
114
|
return null
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
export async function push(args = []) {
|
|
117
|
+
export async function push(args = [], deps = {}) {
|
|
117
118
|
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
118
119
|
// default — including for --backend, where the default can be production.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
//
|
|
121
|
+
// `sync` validates the UNION of its two halves' flags and forwards raw argv, so a
|
|
122
|
+
// legal `uniweb sync --no-git` would be rejected here. It passes `skipFlagCheck`.
|
|
123
|
+
if (!deps.skipFlagCheck) {
|
|
124
|
+
const bad = checkFlags('push', args)
|
|
125
|
+
if (bad) {
|
|
126
|
+
error(bad.message)
|
|
127
|
+
return { exitCode: 2 }
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
130
|
const dryRun = args.includes('--dry-run')
|
|
125
131
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
@@ -241,6 +247,7 @@ export async function push(args = []) {
|
|
|
241
247
|
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
242
248
|
// rewrite too, or every entity reads as changed forever.
|
|
243
249
|
let assetRewrite = null
|
|
250
|
+
let assetIds = null
|
|
244
251
|
if (!output && !dryRun) {
|
|
245
252
|
let mediaRefs = []
|
|
246
253
|
try {
|
|
@@ -271,7 +278,7 @@ export async function push(args = []) {
|
|
|
271
278
|
}
|
|
272
279
|
info('Uploading media…')
|
|
273
280
|
try {
|
|
274
|
-
const { map, failed } = await uploadSiteMedia(
|
|
281
|
+
const { map, ids, failed } = await uploadSiteMedia(
|
|
275
282
|
client,
|
|
276
283
|
siteDir,
|
|
277
284
|
mediaRefs,
|
|
@@ -294,9 +301,18 @@ export async function push(args = []) {
|
|
|
294
301
|
return { exitCode: 1 }
|
|
295
302
|
}
|
|
296
303
|
if (Object.keys(map).length) assetRewrite = map
|
|
304
|
+
if (Object.keys(ids).length) assetIds = ids
|
|
297
305
|
note(
|
|
298
306
|
`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`
|
|
299
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
|
+
}
|
|
300
316
|
} catch (err) {
|
|
301
317
|
// Typed plan refusals get their own account. Note the storage one must not
|
|
302
318
|
// be phrased from what moved — see describeAssetRefusal's rule 1; a push can
|
|
@@ -336,7 +352,8 @@ export async function push(args = []) {
|
|
|
336
352
|
baseVersions: readBaseVersions(siteDir),
|
|
337
353
|
itemBaseVersions: readItemBaseVersions(siteDir)
|
|
338
354
|
}),
|
|
339
|
-
...(assetRewrite ? { assetRewrite } : {})
|
|
355
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
356
|
+
...(assetIds ? { assetIds } : {})
|
|
340
357
|
})
|
|
341
358
|
} catch (err) {
|
|
342
359
|
error(`Could not build the sync package: ${err.message}`)
|
package/src/commands/refresh.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
headProvenance
|
|
47
47
|
} from '../utils/git.js'
|
|
48
48
|
import { probeUnpushed } from '../backend/site-sync.js'
|
|
49
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
49
50
|
|
|
50
51
|
const c = {
|
|
51
52
|
reset: '\x1b[0m',
|
|
@@ -98,6 +99,21 @@ function siteContentUuid(siteDir) {
|
|
|
98
99
|
* network or a real backend).
|
|
99
100
|
*/
|
|
100
101
|
export async function refresh(args = [], deps = {}) {
|
|
102
|
+
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
103
|
+
// default — and for `--backend` the default can be production. This verb never
|
|
104
|
+
// sent data anywhere, which is why it went unguarded; but it DELEGATES to `pull`,
|
|
105
|
+
// and a mistyped `--backend` is not forwarded, so the pull runs against whatever
|
|
106
|
+
// the origin ladder resolves instead of the backend the user named.
|
|
107
|
+
//
|
|
108
|
+
// `sync` validates the UNION of both halves and forwards raw argv, so a legal
|
|
109
|
+
// `uniweb sync --force` would be rejected here. It passes `skipFlagCheck`.
|
|
110
|
+
if (!deps.skipFlagCheck) {
|
|
111
|
+
const bad = checkFlags('refresh', args)
|
|
112
|
+
if (bad) {
|
|
113
|
+
say.err(bad.message)
|
|
114
|
+
return { exitCode: 2 }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
101
117
|
const skipGit = args.includes('--no-git')
|
|
102
118
|
const skipBackend = args.includes('--no-backend')
|
|
103
119
|
const resolveSite = deps.resolveSiteDir || resolveSiteDir
|
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/commands/sync.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
* uniweb sync --backend <url> override the backend origin
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
42
|
+
|
|
41
43
|
const c = {
|
|
42
44
|
reset: '\x1b[0m',
|
|
43
45
|
dim: '\x1b[2m',
|
|
@@ -55,6 +57,18 @@ const say = {
|
|
|
55
57
|
* @param {object} [deps] - injectable seams for testing: `refresh`, `push`.
|
|
56
58
|
*/
|
|
57
59
|
export async function sync(args = [], deps = {}) {
|
|
60
|
+
// THE ONE flag check for the whole composite, against the union of both halves'
|
|
61
|
+
// accepted sets (`VERBS.sync`, derived in flag-guard.js). It has to happen here
|
|
62
|
+
// and only here: each half's set is a strict subset, so `push` would reject
|
|
63
|
+
// `--no-git` and `refresh` would reject `--force` — both legal on `sync`. Hence
|
|
64
|
+
// `skipFlagCheck` below; the halves are not being trusted, they are being told
|
|
65
|
+
// the caller already validated a superset.
|
|
66
|
+
const bad = checkFlags('sync', args)
|
|
67
|
+
if (bad) {
|
|
68
|
+
say.err(bad.message)
|
|
69
|
+
return { exitCode: 2 }
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
const refresh = deps.refresh || (await import('./refresh.js')).refresh
|
|
59
73
|
const push = deps.push || (await import('./push.js')).push
|
|
60
74
|
|
|
@@ -63,7 +77,7 @@ export async function sync(args = [], deps = {}) {
|
|
|
63
77
|
// to send — the same word meaning opposite things on the two halves.
|
|
64
78
|
const refreshArgs = args.filter((a) => a !== '--force')
|
|
65
79
|
|
|
66
|
-
const r = await refresh(refreshArgs)
|
|
80
|
+
const r = await refresh(refreshArgs, { skipFlagCheck: true })
|
|
67
81
|
if (r?.exitCode) {
|
|
68
82
|
// refresh already explained itself — conflicts, or a git failure. Adding a
|
|
69
83
|
// second summary here would just bury it.
|
|
@@ -73,7 +87,7 @@ export async function sync(args = [], deps = {}) {
|
|
|
73
87
|
|
|
74
88
|
console.log('')
|
|
75
89
|
say.info('Pushing your changes…')
|
|
76
|
-
const p = await push(args)
|
|
90
|
+
const p = await push(args, { skipFlagCheck: true })
|
|
77
91
|
return { exitCode: p?.exitCode ?? 0 }
|
|
78
92
|
}
|
|
79
93
|
|
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
|
},
|
|
@@ -111,7 +114,7 @@
|
|
|
111
114
|
"deps": []
|
|
112
115
|
},
|
|
113
116
|
"@uniweb/unipress": {
|
|
114
|
-
"version": "0.8.
|
|
117
|
+
"version": "0.8.10",
|
|
115
118
|
"path": "framework/unipress",
|
|
116
119
|
"deps": [
|
|
117
120
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -1544,6 +1544,58 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1544
1544
|
In non-interactive mode (CI / no TTY), pass \`--token <bearer>\`, or set
|
|
1545
1545
|
\`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set \`UNIWEB_TOKEN\` (used per-command,
|
|
1546
1546
|
not stored). Run \`uniweb logout\` to clear the stored session.
|
|
1547
|
+
`,
|
|
1548
|
+
refresh: `
|
|
1549
|
+
${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
|
|
1550
|
+
|
|
1551
|
+
${colors.bright}Usage:${colors.reset}
|
|
1552
|
+
uniweb refresh [options]
|
|
1553
|
+
|
|
1554
|
+
A developer on a synced site has TWO external sources: the git remote (teammates'
|
|
1555
|
+
commits) and the backend (content authors' edits in the app). Run only \`git pull\`
|
|
1556
|
+
and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
|
|
1557
|
+
This does both — git first, then \`pull --merge\`.
|
|
1558
|
+
|
|
1559
|
+
${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
|
|
1560
|
+
reflexively. Exits non-zero if a merge leaves conflicts.
|
|
1561
|
+
|
|
1562
|
+
${colors.bright}Options:${colors.reset}
|
|
1563
|
+
--no-git Skip the git remote; backend only
|
|
1564
|
+
--no-backend Skip the backend; git only
|
|
1565
|
+
--backend <url> Override the backend origin
|
|
1566
|
+
--token <bearer> Read with this bearer; skips \`uniweb login\`
|
|
1567
|
+
|
|
1568
|
+
\`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
|
|
1569
|
+
pull's arguments itself, and to pull \`--force\` means "discard my local work".
|
|
1570
|
+
Use \`uniweb sync\` when you also mean to share.
|
|
1571
|
+
`,
|
|
1572
|
+
sync: `
|
|
1573
|
+
${colors.cyan}${colors.bright}uniweb sync${colors.reset} ${colors.dim}— Catch up, then share${colors.reset}
|
|
1574
|
+
|
|
1575
|
+
${colors.bright}Usage:${colors.reset}
|
|
1576
|
+
uniweb sync [options]
|
|
1577
|
+
|
|
1578
|
+
\`uniweb refresh\` followed by \`uniweb push\`, and nothing more. It adds no
|
|
1579
|
+
guarantees of its own — refresh stops on conflicts, push refuses when the backend
|
|
1580
|
+
has moved under you, and sync inherits both. \`uniweb refresh && uniweb push\`
|
|
1581
|
+
behaves identically.
|
|
1582
|
+
|
|
1583
|
+
${colors.bright}It does NOT publish.${colors.reset} Sync brings your copy and the backend DRAFT into
|
|
1584
|
+
agreement; going live stays a separate act (\`uniweb publish\`). So the worst case
|
|
1585
|
+
of an unintended sync is work-in-progress visible to authors, not shipped to
|
|
1586
|
+
visitors.
|
|
1587
|
+
|
|
1588
|
+
${colors.bright}Options:${colors.reset}
|
|
1589
|
+
Accepts every option of both halves — see \`uniweb refresh --help\` and the push
|
|
1590
|
+
options in \`uniweb --help\`. The common ones:
|
|
1591
|
+
|
|
1592
|
+
--no-git Skip the git remote half of the refresh
|
|
1593
|
+
--force Overwrite upstream changes (reaches the PUSH half only)
|
|
1594
|
+
--backend <url> Override the backend origin
|
|
1595
|
+
--token <bearer> Auth bearer; skips \`uniweb login\`
|
|
1596
|
+
|
|
1597
|
+
\`--force\` means "overwrite upstream" to push but "discard my local work" to pull,
|
|
1598
|
+
so it is deliberately kept away from the refresh half.
|
|
1547
1599
|
`,
|
|
1548
1600
|
invite: `
|
|
1549
1601
|
${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
|
@@ -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',
|
|
@@ -85,9 +86,44 @@ const VERBS = {
|
|
|
85
86
|
'--backend', '--json', '--registry', '--remote', '--token', '--dry-run',
|
|
86
87
|
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
|
|
87
88
|
...VIA_DEPLOY
|
|
89
|
+
],
|
|
90
|
+
/**
|
|
91
|
+
* `refresh` = `git pull`, then a DELEGATED `pull --merge`.
|
|
92
|
+
*
|
|
93
|
+
* It forwards exactly three flags to that pull — `--backend` / `--registry` /
|
|
94
|
+
* `--token`, via its own `collectPassthrough` — and constructs the rest of the
|
|
95
|
+
* argv itself. So pull's own flags (`--merge`, `--force`, `--no-delete`,
|
|
96
|
+
* `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
97
|
+
* are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
|
|
98
|
+
* design, and forwarding it would ask pull to DISCARD local work.
|
|
99
|
+
*
|
|
100
|
+
* The trailing group is inert on this verb but reachable through the
|
|
101
|
+
* `resolveSiteDir` / `probeUnpushed` imports — listed rather than filtered, per
|
|
102
|
+
* the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
|
|
103
|
+
*/
|
|
104
|
+
refresh: [
|
|
105
|
+
'--backend', '--no-backend', '--no-git', '--registry', '--token',
|
|
106
|
+
'--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
88
107
|
]
|
|
89
108
|
}
|
|
90
109
|
|
|
110
|
+
/**
|
|
111
|
+
* `sync` is `refresh` + `push`, forwarding RAW argv to both halves — so its
|
|
112
|
+
* accepted set is exactly the UNION of theirs. Derived, never hand-written.
|
|
113
|
+
*
|
|
114
|
+
* Hand-writing it would rot the moment either half gained a flag, and it would rot
|
|
115
|
+
* in the expensive direction: `uniweb sync --no-git` and `uniweb sync --force` are
|
|
116
|
+
* both documented in `sync.js`, and a union that forgot either would reject a
|
|
117
|
+
* working command.
|
|
118
|
+
*
|
|
119
|
+
* ⭐ The union is also WHY `sync` checks and the two halves skip (`skipFlagCheck`,
|
|
120
|
+
* see `sync.js`). Each half's set is a strict subset of this one, so letting them
|
|
121
|
+
* re-check would have `push` reject `--no-git` and `refresh` reject `--force` —
|
|
122
|
+
* both legal on `sync`. Validating once, against the union, at the only layer that
|
|
123
|
+
* knows both halves are in play, is what makes the composite safe.
|
|
124
|
+
*/
|
|
125
|
+
VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
|
|
126
|
+
|
|
91
127
|
/**
|
|
92
128
|
* The accepted set per verb: its own flags, plus the login-method flags every
|
|
93
129
|
* backend verb can reach, plus the globals.
|
|
@@ -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
|