uniweb 0.13.6 → 0.13.8
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 +7 -7
- package/partials/agents.md +26 -1
- package/src/backend/site-media.js +34 -5
- package/src/commands/publish.js +17 -2
- package/src/commands/push.js +71 -0
- package/src/commands/update.js +100 -27
- package/src/framework-index.json +10 -10
- package/src/utils/update-check.js +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.8",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/kit": "0.9.
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/kit": "0.9.40",
|
|
45
|
+
"@uniweb/runtime": "0.8.38",
|
|
46
|
+
"@uniweb/core": "0.7.31"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/content-reader": "1.1.17",
|
|
50
|
+
"@uniweb/semantic-parser": "1.1.20",
|
|
51
|
+
"@uniweb/build": "0.15.8"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -472,6 +472,8 @@ Optional attributes: `{size=20}`, `{color=red}`. Custom SVGs: `, and a value containing a **comma** must be quoted (`{style="a, b"}`). A value containing a colon needs no quoting — only the first colon separates, so `{href:https://example.com}` is fine.
|
|
476
|
+
|
|
475
477
|
Standalone links (alone on a line) become buttons in `content.links[]`. Inline links stay as `<a>` tags inside `content.paragraphs[]`. Multiple links sharing a paragraph are all promoted:
|
|
476
478
|
|
|
477
479
|
```markdown
|
|
@@ -1278,8 +1280,9 @@ Pages are sequences of sections — the obvious layer. The framework also suppor
|
|
|
1278
1280
|
| **Items** (`content.items`) | Heading groups within one `.md` | Repeating content in one section: cards, features, FAQ entries |
|
|
1279
1281
|
| **Child sections** (`block.childBlocks`) | `@`-prefixed `.md` files + `nest:` | Children needing their own section type, rich content, or independent editing |
|
|
1280
1282
|
| **Insets** (`block.insets`) | `` in markdown | Self-contained visuals/widgets: charts, diagrams, code demos |
|
|
1283
|
+
| **Block insets** (`block.insets`) | ` ```@Component ` fence around markdown | A component that *wraps* authored prose: callouts, disclosures, admonitions |
|
|
1281
1284
|
|
|
1282
|
-
Does the author write content *inside* the nested element? **Yes** → child sections. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children.
|
|
1285
|
+
Does the author write content *inside* the nested element? **Yes** → child sections, or a block inset when the wrapper is presentational and lives mid-page. **No** (self-contained, param-driven) → inset. Repeating same-structure groups → items. These compose: a child section can contain insets; items work inside children; a block inset can contain both.
|
|
1283
1286
|
|
|
1284
1287
|
**Insets — embedding components in content.** Many section types need a "visual" — a hero's illustration, a split-content section's media. Classically an image or video. But what if it's a JSX + SVG diagram, a ThreeJS animation, an interactive playground? Elsewhere you'd reach for MDX or prop-drilling. Here the author writes standard image syntax:
|
|
1285
1288
|
|
|
@@ -1297,6 +1300,23 @@ The developer builds `NetworkDiagram` as an ordinary React component with `inset
|
|
|
1297
1300
|
|
|
1298
1301
|
**Don't use `hidden: true` on insets.** `hidden` means "don't export this component at all" (internal helpers); `inset: true` means "available for `@Component` references in markdown."
|
|
1299
1302
|
|
|
1303
|
+
**Block insets — a component that wraps content.** The image form is a leaf: it takes params but no body. When the component should surround authored prose — a callout, a disclosure, an admonition — use the fenced form instead. Same `@Component{params}` reference, written as a code fence's info string:
|
|
1304
|
+
|
|
1305
|
+
````markdown
|
|
1306
|
+
```@Alert{type=warning}
|
|
1307
|
+
Back up your database **before** running this.
|
|
1308
|
+
|
|
1309
|
+
- The migration is not reversible
|
|
1310
|
+
- Allow ten minutes of downtime
|
|
1311
|
+
```
|
|
1312
|
+
````
|
|
1313
|
+
|
|
1314
|
+
The body is ordinary content, not text: it is parsed exactly like the rest of the page, so headings, lists, tables, icons, inline styling, leaf insets and further containers all work inside one. To nest a code block or another container, open the outer fence with more backticks than the inner one.
|
|
1315
|
+
|
|
1316
|
+
**The component is yours, and it receives parsed content.** `@Alert` resolves against the foundation through the same lookup as `` — declare it with `inset: true` in `meta.js`. Where a leaf inset gets its alt text as `content.title` and nothing else, a container gets its whole body parsed: `title`, `paragraphs`, `items`, `sequence`. Render it with `<Prose content={content} block={block} />` or read the fields directly. A name the foundation doesn't define falls back to a plain bordered box that still shows the body — never a drop.
|
|
1317
|
+
|
|
1318
|
+
Prefer a **child section** when the author needs their own section type and independent editing; prefer a **block inset** when the wrapper is presentational and belongs inline in a single file's prose.
|
|
1319
|
+
|
|
1300
1320
|
**Child sections.** You hit a complex layout — a 2:1 split with a panel and a main area. Your instinct says build a specialized component. Step back: the panel is a reusable section type, the main area is another, and the split is a Grid with `columns: "1fr 2fr"`. Your child components already adapt to narrow containers — container queries handle that. But hardcoding which components go where means the author can't rearrange or swap them. Child sections solve that:
|
|
1301
1321
|
|
|
1302
1322
|
```
|
|
@@ -1664,6 +1684,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
|
|
|
1664
1684
|
|
|
1665
1685
|
uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
|
|
1666
1686
|
uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
|
|
1687
|
+
uniweb login / logout # Start or clear the backend session the verbs above reuse
|
|
1688
|
+
uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
|
|
1689
|
+
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
1667
1690
|
|
|
1668
1691
|
uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
|
|
1669
1692
|
uniweb i18n extract / init / sync / status / audit # Translation workflow (build first)
|
|
@@ -1678,6 +1701,8 @@ uniweb inspect <path> # Show parsed content for a section or page (-
|
|
|
1678
1701
|
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
1679
1702
|
```
|
|
1680
1703
|
|
|
1704
|
+
**Four verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. `runtime register` uploads a built runtime and is internal. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
|
|
1705
|
+
|
|
1681
1706
|
### Where a site can live
|
|
1682
1707
|
|
|
1683
1708
|
**There is no lock-in, and no default you're pushed toward.** Four independent paths, and a project can change its mind:
|
|
@@ -24,16 +24,24 @@ import { contentTypeFor } from '../utils/code-upload.js'
|
|
|
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 {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
|
|
27
|
-
* @returns {Promise<Record<string,string
|
|
27
|
+
* @returns {Promise<{ map: Record<string,string>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
|
|
28
|
+
* `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
|
|
29
|
+
* kinds are reported SEPARATELY because callers must treat them differently:
|
|
30
|
+
* `missing` is a ref with no file under the site — an authoring mistake, already
|
|
31
|
+
* broken before us, worth a warning; `failed` is a ref whose bytes we could not
|
|
32
|
+
* store — a transport or QUOTA refusal, and shipping content that still points at
|
|
33
|
+
* the local path would publish a broken image while only warning about it.
|
|
28
34
|
*/
|
|
29
35
|
export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
|
|
30
|
-
if (!refs?.length) return {}
|
|
36
|
+
if (!refs?.length) return { map: {}, missing: [], failed: [] }
|
|
31
37
|
|
|
32
38
|
const files = []
|
|
39
|
+
const missing = []
|
|
33
40
|
for (const ref of refs) {
|
|
34
41
|
const { resolved } = resolveAssetPath(ref, siteDir, siteDir)
|
|
35
42
|
if (!resolved || !existsSync(resolved)) {
|
|
36
43
|
warn?.(`local-media: ${ref} not found under the site (skipped)`)
|
|
44
|
+
missing.push(ref)
|
|
37
45
|
continue
|
|
38
46
|
}
|
|
39
47
|
const bytes = readFileSync(resolved)
|
|
@@ -46,10 +54,11 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
|
|
|
46
54
|
diskPath: resolved,
|
|
47
55
|
})
|
|
48
56
|
}
|
|
49
|
-
if (!files.length) return {}
|
|
57
|
+
if (!files.length) return { map: {}, missing, failed: [] }
|
|
50
58
|
|
|
51
59
|
const result = await client.uploadSiteAssets({ files, onProgress })
|
|
52
|
-
|
|
60
|
+
const failed = result.failed || []
|
|
61
|
+
for (const f of failed) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
|
|
53
62
|
|
|
54
63
|
const config = await client.discover()
|
|
55
64
|
const map = {}
|
|
@@ -57,5 +66,25 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
|
|
|
57
66
|
const entry = result.assetsByLocalUrl[ref]
|
|
58
67
|
if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
|
|
59
68
|
}
|
|
60
|
-
return map
|
|
69
|
+
return { map, missing, failed }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is this asset-lane error the backend refusing on storage grounds?
|
|
74
|
+
*
|
|
75
|
+
* The plan step throws `Asset plan failed: HTTP <status>` for any non-2xx, so a quota
|
|
76
|
+
* refusal is currently indistinguishable from any other rejection except by status.
|
|
77
|
+
* These three are the plausible spellings — 402 (payment required), 413 (payload too
|
|
78
|
+
* large), 507 (insufficient storage).
|
|
79
|
+
*
|
|
80
|
+
* DELIBERATELY a heuristic, and it should not stay one: the backend owes a typed
|
|
81
|
+
* error carrying used / limit / needed so the CLI can say what a push costs and what
|
|
82
|
+
* is left. Until that contract exists this at least turns an opaque HTTP number into
|
|
83
|
+
* the right advice. See the collab charter in the handoff.
|
|
84
|
+
*
|
|
85
|
+
* @param {Error} err
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isStorageRefusal(err) {
|
|
89
|
+
return /Asset plan failed: HTTP (402|413|507)\b/.test(err?.message || '')
|
|
61
90
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
pushSyncPackages,
|
|
66
66
|
} from '../backend/site-sync.js'
|
|
67
67
|
import { uploadDataBundle } from '../backend/data-bundle.js'
|
|
68
|
-
import { uploadSiteMedia } from '../backend/site-media.js'
|
|
68
|
+
import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
|
|
69
69
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
70
70
|
import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
|
|
71
71
|
|
|
@@ -275,14 +275,29 @@ export async function publish(args = []) {
|
|
|
275
275
|
if (mediaRefs.length) {
|
|
276
276
|
say.info('Uploading media…')
|
|
277
277
|
try {
|
|
278
|
-
const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
278
|
+
const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
279
279
|
onProgress: (m) => say.dim(` ${m}`),
|
|
280
280
|
warn: (m) => say.dim(`! ${m}`),
|
|
281
281
|
})
|
|
282
|
+
// A ref whose bytes did not land must NOT be published: the content would go
|
|
283
|
+
// out still pointing at the local path, so the site ships a broken image and
|
|
284
|
+
// the only trace is a warning nobody reads. A missing FILE is different —
|
|
285
|
+
// already broken before us, warned above, and not worth blocking a publish.
|
|
286
|
+
if (failed.length) {
|
|
287
|
+
say.err(`${failed.length} asset(s) failed to upload — not publishing.`)
|
|
288
|
+
for (const f of failed) say.dim(` ${f.path} (HTTP ${f.status})`)
|
|
289
|
+
return { exitCode: 1 }
|
|
290
|
+
}
|
|
282
291
|
if (Object.keys(map).length) assetRewrite = map
|
|
283
292
|
if (ballAssets.length) ball = rewriteBallAssets(ball, map)
|
|
284
293
|
say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
|
|
285
294
|
} catch (err) {
|
|
295
|
+
if (isStorageRefusal(err)) {
|
|
296
|
+
say.err('Storage quota reached — the media in this publish does not fit.')
|
|
297
|
+
say.dim(' Remove or shrink assets, or raise the plan limit, then re-run.')
|
|
298
|
+
say.dim(` ${err.message}`)
|
|
299
|
+
return { exitCode: 1 }
|
|
300
|
+
}
|
|
286
301
|
say.err(`Media upload failed: ${err.message}`)
|
|
287
302
|
return { exitCode: 1 }
|
|
288
303
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
import { writeFileSync } from 'node:fs'
|
|
56
56
|
import { resolve } from 'node:path'
|
|
57
57
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
58
|
+
import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
|
|
58
59
|
import { BackendClient } from '../backend/client.js'
|
|
59
60
|
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
60
61
|
import {
|
|
@@ -131,6 +132,75 @@ export async function push(args = []) {
|
|
|
131
132
|
// to what a real push would send. Only the network RECOVERY is skipped, so a
|
|
132
133
|
// preview never reaches the backend. (Emitting `{}` here instead would make the
|
|
133
134
|
// preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
|
|
135
|
+
// Local media rides the SAME asset lane `publish` uses, and it rides it FIRST.
|
|
136
|
+
//
|
|
137
|
+
// Push is the collaboration verb: a teammate opens the site in the visual app
|
|
138
|
+
// right after it. Content that still points at `/images/hero.png` — bytes the
|
|
139
|
+
// backend never received — shows them a broken image, which is precisely what
|
|
140
|
+
// push exists to avoid. `publish` uploaded and rewrote; push dropped
|
|
141
|
+
// `localAssets` on the floor.
|
|
142
|
+
//
|
|
143
|
+
// Ordering is deliberate: BEFORE `ensureItemUuids`, which mints uuids on the
|
|
144
|
+
// backend. A refusal here then leaves nothing minted and nothing submitted.
|
|
145
|
+
//
|
|
146
|
+
// The probe emit runs WITHOUT `priorHashes`, so it surfaces every local ref
|
|
147
|
+
// rather than only the changed ones. That is not waste — the lane is
|
|
148
|
+
// content-addressed with a `present` skip-list, so unchanged bytes are a no-op
|
|
149
|
+
// PUT. It is also what makes the storage rule fall out of the mechanism instead
|
|
150
|
+
// of needing a special case: a content-only push presents the same plan as last
|
|
151
|
+
// time, every file is already present, zero new bytes are requested, and no
|
|
152
|
+
// quota check can refuse it. Only a push that genuinely ADDS bytes can be
|
|
153
|
+
// blocked.
|
|
154
|
+
//
|
|
155
|
+
// It also has to be this emit that carries `assetRewrite` below: the push cache
|
|
156
|
+
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
157
|
+
// rewrite too, or every entity reads as changed forever.
|
|
158
|
+
let assetRewrite = null
|
|
159
|
+
if (!output && !dryRun) {
|
|
160
|
+
let mediaRefs = []
|
|
161
|
+
try {
|
|
162
|
+
const probe = await emitSyncPackages(siteDir, {
|
|
163
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
164
|
+
resolveModel: makeModelResolver({ client, offline: false }),
|
|
165
|
+
})
|
|
166
|
+
mediaRefs = probe.localAssets || []
|
|
167
|
+
} catch (err) {
|
|
168
|
+
error(`Could not scan the site for local media: ${err.message}`)
|
|
169
|
+
return { exitCode: 2 }
|
|
170
|
+
}
|
|
171
|
+
if (mediaRefs.length) {
|
|
172
|
+
info('Uploading media…')
|
|
173
|
+
try {
|
|
174
|
+
const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
175
|
+
onProgress: (m) => note(` ${m}`),
|
|
176
|
+
warn: (m) => note(`! ${m}`),
|
|
177
|
+
})
|
|
178
|
+
// Bytes that did not land must not be pushed around: the content would go
|
|
179
|
+
// up still naming the local path, so the teammate sees the broken image
|
|
180
|
+
// this whole change exists to prevent, and the only trace is a warning. A
|
|
181
|
+
// missing FILE is a different thing — already broken before us, warned by
|
|
182
|
+
// the uploader, and not worth blocking a push over.
|
|
183
|
+
if (failed.length) {
|
|
184
|
+
error(`${failed.length} asset(s) failed to upload — nothing was pushed.`)
|
|
185
|
+
for (const f of failed) note(` ${f.path} (HTTP ${f.status})`)
|
|
186
|
+
return { exitCode: 1 }
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(map).length) assetRewrite = map
|
|
189
|
+
note(`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (isStorageRefusal(err)) {
|
|
192
|
+
error('Storage quota reached — this push adds media that does not fit.')
|
|
193
|
+
note(' A push that changes only content costs no storage and still works.')
|
|
194
|
+
note(' Remove or shrink the new assets, or raise the plan limit, then re-run.')
|
|
195
|
+
note(` ${err.message}`)
|
|
196
|
+
return { exitCode: 1 }
|
|
197
|
+
}
|
|
198
|
+
error(`Media upload failed: ${err.message}`)
|
|
199
|
+
return { exitCode: 1 }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
134
204
|
const itemUuids = (output || dryRun)
|
|
135
205
|
? readItemUuids(siteDir)
|
|
136
206
|
: await ensureItemUuids({ client, siteDir, note })
|
|
@@ -145,6 +215,7 @@ export async function push(args = []) {
|
|
|
145
215
|
// Both grains are dropped together by --force: one flag, one meaning,
|
|
146
216
|
// no partial-force mode.
|
|
147
217
|
...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
|
|
218
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
148
219
|
})
|
|
149
220
|
} catch (err) {
|
|
150
221
|
error(`Could not build the sync package: ${err.message}`)
|
package/src/commands/update.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* --no-agents Skip the AGENTS.md step.
|
|
32
32
|
* --no-deps Skip the deps-alignment step.
|
|
33
33
|
* --dry-run Print survey + would-be writes; no mutations.
|
|
34
|
+
* --verbose List every surveyed dep, including aligned ones
|
|
35
|
+
* (the default collapses those to a count).
|
|
34
36
|
* --allow-mismatch Refresh AGENTS.md even if declared deps lag.
|
|
35
37
|
* --yes Don't prompt — apply edits and run the install.
|
|
36
38
|
* --non-interactive Auto-detected; prints the plan, never mutates
|
|
@@ -50,6 +52,7 @@ import { detectWorkspacePm, installCmd, detectGlobalCliPm, globalCliUpdateCmd }
|
|
|
50
52
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
51
53
|
import { surveyWorkspaceDeps, compareSemver } from '../utils/dep-survey.js'
|
|
52
54
|
import { checkWorkspaceInstall, readDeclaredFoundation } from '../utils/install-integrity.js'
|
|
55
|
+
import { getLatestVersion } from '../utils/update-check.js'
|
|
53
56
|
|
|
54
57
|
const colors = {
|
|
55
58
|
reset: '\x1b[0m',
|
|
@@ -110,16 +113,45 @@ function findUniwebWorkspace(cwd) {
|
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Trailing advisory: a newer CLI exists, and this run could not have used it.
|
|
118
|
+
*
|
|
119
|
+
* Placed AFTER the work, not before, because it is a what-to-do-next — the
|
|
120
|
+
* same reason update-check calls its post-command notice the 'soft' tone.
|
|
121
|
+
* It also has to be the last thing on screen when it fires: a run that ends
|
|
122
|
+
* on `✓ deps aligned` + `✓ AGENTS.md up to date` reads as "all good", and
|
|
123
|
+
* those ticks are measured against THIS CLI's matrix. True, and beside the
|
|
124
|
+
* point, when the CLI itself is two releases back.
|
|
125
|
+
*
|
|
126
|
+
* The remedy is per-provenance because the wrong one is useless advice:
|
|
127
|
+
* - project-local — the version is pinned by the project's package.json,
|
|
128
|
+
* so a global install is irrelevant. `npx uniweb@latest update` both
|
|
129
|
+
* aligns the deps AND rewrites that pin, so it is self-healing; say so,
|
|
130
|
+
* or the reader assumes they'll be back here next release.
|
|
131
|
+
* - global — updating the global install is the durable fix.
|
|
132
|
+
* - npx — skipped entirely. The version was chosen explicitly on the
|
|
133
|
+
* command line, and the lead-in already named it.
|
|
134
|
+
*
|
|
135
|
+
* Exported for tests: this notice must fire when behind and stay silent
|
|
136
|
+
* when current, and it's the half of the command that had no coverage.
|
|
137
|
+
*
|
|
138
|
+
* @returns {boolean} true if a notice was printed.
|
|
139
|
+
*/
|
|
140
|
+
export function printStaleCliNotice({ cliVersion, latest, isNpx, isGlobal, globalPm }) {
|
|
141
|
+
if (isNpx) return false
|
|
142
|
+
if (!latest || compareSemver(latest, cliVersion) <= 0) return false
|
|
143
|
+
|
|
144
|
+
log('')
|
|
145
|
+
log(`${colors.yellow}⚠${colors.reset} ${colors.bright}A newer uniweb is available:${colors.reset} ${colors.dim}v${cliVersion}${colors.reset} → ${colors.cyan}v${latest}${colors.reset}`)
|
|
146
|
+
if (isGlobal) {
|
|
147
|
+
log(`${colors.dim}This run aligned the project to v${cliVersion}'s matrix. To update the CLI:${colors.reset} ${colors.cyan}${globalCliUpdateCmd(globalPm)}${colors.reset}`)
|
|
148
|
+
log(`${colors.dim}Or align to the latest release without a global install:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}`)
|
|
149
|
+
} else {
|
|
150
|
+
log(`${colors.dim}This run aligned the project to v${cliVersion}'s matrix — the version your project pins.${colors.reset}`)
|
|
151
|
+
log(`${colors.dim}To move to v${latest}:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset} ${colors.dim}(updates the pin too).${colors.reset}`)
|
|
122
152
|
}
|
|
153
|
+
log('')
|
|
154
|
+
return true
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
/** Run a shell command, inheriting stdio. Resolves with the exit code. */
|
|
@@ -132,8 +164,22 @@ function runCommand(cmd, cwd) {
|
|
|
132
164
|
})
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Print the survey report grouped by package directory.
|
|
169
|
+
*
|
|
170
|
+
* Only rows that need attention get a line. An aligned dep renders as
|
|
171
|
+
* `0.9.37 → 0.9.37 aligned` — an identity mapping that says nothing, and
|
|
172
|
+
* on a typical workspace there are six of them. That is the whole screen
|
|
173
|
+
* on the command's most common outcome (a no-op), and it buries the one
|
|
174
|
+
* line that isn't noise. Aligned deps collapse to a count instead.
|
|
175
|
+
*
|
|
176
|
+
* `--verbose` restores the full table for anyone auditing the matrix.
|
|
177
|
+
*
|
|
178
|
+
* Exported for tests: the collapse is the point of the function, and a
|
|
179
|
+
* regression here is invisible — the command still works, it just stops
|
|
180
|
+
* being readable.
|
|
181
|
+
*/
|
|
182
|
+
export function printSurvey(report, cliVersion, agentsVersion, { verbose = false } = {}) {
|
|
137
183
|
log('')
|
|
138
184
|
log(`${colors.bright}uniweb CLI:${colors.reset} v${cliVersion}`)
|
|
139
185
|
log(`${colors.bright}AGENTS.md stamp:${colors.reset} ${agentsVersion ? 'v' + agentsVersion : colors.dim + '(none)' + colors.reset}`)
|
|
@@ -145,8 +191,16 @@ function printSurvey(report, cliVersion, agentsVersion) {
|
|
|
145
191
|
return
|
|
146
192
|
}
|
|
147
193
|
|
|
194
|
+
const needsAttention = report.rows.filter(r => r.status !== 'aligned')
|
|
195
|
+
const shown = verbose ? report.rows : needsAttention
|
|
196
|
+
const alignedCount = report.rows.length - needsAttention.length
|
|
197
|
+
|
|
198
|
+
// Everything aligned and not auditing: step 1's success line says so in
|
|
199
|
+
// one sentence. A header over an empty table is worse than no header.
|
|
200
|
+
if (shown.length === 0) return
|
|
201
|
+
|
|
148
202
|
const byDir = {}
|
|
149
|
-
for (const row of
|
|
203
|
+
for (const row of shown) {
|
|
150
204
|
if (!byDir[row.relDir]) byDir[row.relDir] = []
|
|
151
205
|
byDir[row.relDir].push(row)
|
|
152
206
|
}
|
|
@@ -171,6 +225,9 @@ function printSurvey(report, cliVersion, agentsVersion) {
|
|
|
171
225
|
log(` ${icon} ${row.name}${padding} ${row.current.padEnd(10)} → ${row.target.padEnd(10)} ${statusText}`)
|
|
172
226
|
}
|
|
173
227
|
}
|
|
228
|
+
if (!verbose && alignedCount > 0) {
|
|
229
|
+
log(` ${colors.dim}(${alignedCount} other${alignedCount === 1 ? '' : 's'} already aligned — ${colors.reset}${colors.cyan}--verbose${colors.reset}${colors.dim} to list)${colors.reset}`)
|
|
230
|
+
}
|
|
174
231
|
log('')
|
|
175
232
|
}
|
|
176
233
|
|
|
@@ -228,6 +285,7 @@ export async function update(args = []) {
|
|
|
228
285
|
const skipDeps = args.includes('--no-deps') || agentsOnly
|
|
229
286
|
const dryRun = args.includes('--dry-run')
|
|
230
287
|
const allowMismatch = args.includes('--allow-mismatch')
|
|
288
|
+
const verbose = args.includes('--verbose')
|
|
231
289
|
const hasYes = args.includes('--yes')
|
|
232
290
|
const nonInteractive = isNonInteractive(args)
|
|
233
291
|
const isGlobal = isGlobalInstall()
|
|
@@ -248,30 +306,39 @@ export async function update(args = []) {
|
|
|
248
306
|
if (inProject) {
|
|
249
307
|
survey = await surveyWorkspaceDeps(workspaceDir)
|
|
250
308
|
agentsVersion = readAgentsVersion(join(workspaceDir, 'AGENTS.md'))
|
|
251
|
-
printSurvey(survey, cliVersion, agentsVersion)
|
|
309
|
+
printSurvey(survey, cliVersion, agentsVersion, { verbose })
|
|
252
310
|
}
|
|
253
311
|
|
|
254
312
|
// ── This command reconciles the *project*, not the CLI ───────────
|
|
255
|
-
//
|
|
256
|
-
//
|
|
313
|
+
// Which CLI is running is a lead-in — it belongs before the work. Whether
|
|
314
|
+
// that CLI is STALE is a what-to-do-next, and it is printed after the work
|
|
315
|
+
// by printStaleCliNotice() so it lands last instead of under two green
|
|
316
|
+
// ticks. The lookup happens here because the check must run on every
|
|
317
|
+
// provenance, not just a global install.
|
|
318
|
+
//
|
|
319
|
+
// Why the project-local path needs it MORE than the global one: a global
|
|
320
|
+
// CLI also gets the general notifier in index.js, but that is gated on
|
|
321
|
+
// `if (global)` — so a project-local run gets no staleness signal from
|
|
322
|
+
// anywhere. That is exactly the case where the user cannot work it out
|
|
323
|
+
// themselves, because the project pins the version. It was the quiet path
|
|
324
|
+
// and it should have been the loud one.
|
|
257
325
|
let installPm = inProject ? detectWorkspacePm(workspaceDir) : null
|
|
326
|
+
const globalPm = isGlobal ? detectGlobalCliPm() : null
|
|
327
|
+
// Non-TTY reads cache only: scripted runs stay fast and offline-safe, the
|
|
328
|
+
// same convention `--version` follows in index.js. Skipped entirely when
|
|
329
|
+
// there is nothing to reconcile — the notice is never reached from those
|
|
330
|
+
// paths, and looking it up anyway would spend a network call on nothing.
|
|
331
|
+
const latestCli = (isNpx || !inProject)
|
|
332
|
+
? null
|
|
333
|
+
: await getLatestVersion({ allowNetwork: !!process.stdout.isTTY })
|
|
334
|
+
|
|
258
335
|
if (isNpx) {
|
|
259
336
|
log(`${colors.dim}Running${colors.reset} ${colors.cyan}uniweb@${cliVersion}${colors.reset} ${colors.dim}via npx — aligning this project to v${cliVersion}'s matrix.${colors.reset}`)
|
|
260
337
|
log(`${colors.dim}(To install the CLI:${colors.reset} ${colors.cyan}npm i -g uniweb${colors.reset}${colors.dim}.)${colors.reset}`)
|
|
261
338
|
log('')
|
|
262
|
-
} else if (isGlobal) {
|
|
263
|
-
const latest = await fetchLatestVersion()
|
|
264
|
-
if (latest && compareSemver(latest, cliVersion) > 0) {
|
|
265
|
-
const pm = detectGlobalCliPm()
|
|
266
|
-
log(`${colors.yellow}A newer uniweb is available:${colors.reset} ${colors.dim}v${cliVersion}${colors.reset} → ${colors.cyan}v${latest}${colors.reset}`)
|
|
267
|
-
log(`${colors.dim}This run aligns the project to v${cliVersion}. To update the CLI:${colors.reset} ${colors.cyan}${globalCliUpdateCmd(pm)}${colors.reset}`)
|
|
268
|
-
log(`${colors.dim}Or, to align to the latest release without a global install:${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}`)
|
|
269
|
-
log('')
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
339
|
+
} else if (!isGlobal) {
|
|
272
340
|
// Project-local copy (lives in this project's node_modules).
|
|
273
341
|
log(`${colors.dim}Running the project-local CLI (v${cliVersion}) — pinned by your project's${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim}.${colors.reset}`)
|
|
274
|
-
log(`${colors.dim}To use a newer CLI, bump${colors.reset} ${colors.cyan}uniweb${colors.reset}${colors.dim} in${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim} and re-install, or run${colors.reset} ${colors.cyan}npx uniweb@latest update${colors.reset}${colors.dim}.${colors.reset}`)
|
|
275
342
|
log('')
|
|
276
343
|
}
|
|
277
344
|
|
|
@@ -289,7 +356,8 @@ export async function update(args = []) {
|
|
|
289
356
|
|
|
290
357
|
if (!skipDeps && survey) {
|
|
291
358
|
if (!survey.anyDrift) {
|
|
292
|
-
|
|
359
|
+
// The count carries the work the collapsed table no longer shows.
|
|
360
|
+
success(`Workspace deps are aligned with the CLI (${survey.rows.length} checked).`)
|
|
293
361
|
if (survey.anyAhead) {
|
|
294
362
|
log(`${colors.dim}(Some deps are ahead of the CLI's bundled matrix — left untouched.)${colors.reset}`)
|
|
295
363
|
}
|
|
@@ -438,6 +506,11 @@ export async function update(args = []) {
|
|
|
438
506
|
if (!dryRun && (depsEdited || agentsResult === 'created' || agentsResult === 'updated')) {
|
|
439
507
|
printSummary({ editedPaths, depsEdited, installRan, installPm, agentsResult, cliVersion })
|
|
440
508
|
}
|
|
509
|
+
|
|
510
|
+
// Last, deliberately — see printStaleCliNotice. Everything above reports
|
|
511
|
+
// against THIS CLI's matrix; if the CLI itself is behind, that is the note
|
|
512
|
+
// the reader should leave with.
|
|
513
|
+
printStaleCliNotice({ cliVersion, latest: latestCli, isNpx, isGlobal, globalPm })
|
|
441
514
|
}
|
|
442
515
|
|
|
443
516
|
/**
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-27T15:44:34.893Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.15.
|
|
6
|
+
"version": "0.15.8",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
18
|
"@uniweb/content-reader": {
|
|
19
|
-
"version": "1.1.
|
|
19
|
+
"version": "1.1.17",
|
|
20
20
|
"path": "framework/content-reader",
|
|
21
21
|
"deps": []
|
|
22
22
|
},
|
|
23
23
|
"@uniweb/content-writer": {
|
|
24
|
-
"version": "0.2.
|
|
24
|
+
"version": "0.2.9",
|
|
25
25
|
"path": "framework/content-writer",
|
|
26
26
|
"deps": []
|
|
27
27
|
},
|
|
28
28
|
"@uniweb/core": {
|
|
29
|
-
"version": "0.7.
|
|
29
|
+
"version": "0.7.31",
|
|
30
30
|
"path": "framework/core",
|
|
31
31
|
"deps": [
|
|
32
32
|
"@uniweb/semantic-parser",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"deps": []
|
|
45
45
|
},
|
|
46
46
|
"@uniweb/kit": {
|
|
47
|
-
"version": "0.9.
|
|
47
|
+
"version": "0.9.40",
|
|
48
48
|
"path": "framework/kit",
|
|
49
49
|
"deps": [
|
|
50
50
|
"@uniweb/core",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"deps": []
|
|
63
63
|
},
|
|
64
64
|
"@uniweb/projections": {
|
|
65
|
-
"version": "0.1.
|
|
65
|
+
"version": "0.1.2",
|
|
66
66
|
"path": "framework/projections",
|
|
67
67
|
"deps": [
|
|
68
68
|
"@uniweb/content-writer",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
]
|
|
71
71
|
},
|
|
72
72
|
"@uniweb/runtime": {
|
|
73
|
-
"version": "0.8.
|
|
73
|
+
"version": "0.8.38",
|
|
74
74
|
"path": "framework/runtime",
|
|
75
75
|
"deps": [
|
|
76
76
|
"@uniweb/core",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"deps": []
|
|
94
94
|
},
|
|
95
95
|
"@uniweb/semantic-parser": {
|
|
96
|
-
"version": "1.1.
|
|
96
|
+
"version": "1.1.20",
|
|
97
97
|
"path": "framework/semantic-parser",
|
|
98
98
|
"deps": []
|
|
99
99
|
},
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"deps": []
|
|
109
109
|
},
|
|
110
110
|
"@uniweb/unipress": {
|
|
111
|
-
"version": "0.5.
|
|
111
|
+
"version": "0.5.7",
|
|
112
112
|
"path": "framework/unipress",
|
|
113
113
|
"deps": [
|
|
114
114
|
"@uniweb/build",
|
|
@@ -103,6 +103,61 @@ export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
|
|
|
103
103
|
// and gets the eager default. Keeps that call site unchanged.
|
|
104
104
|
export const maybeEagerNotification = maybeNotifyFromCache
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the latest published CLI version WITHOUT printing anything.
|
|
108
|
+
*
|
|
109
|
+
* The notify helpers in this module own both the lookup and the message.
|
|
110
|
+
* That's right for callers who want the house notice, and wrong for callers
|
|
111
|
+
* whose remedy differs — `uniweb update` running from a project's
|
|
112
|
+
* node_modules can't tell the user to `npm i -g uniweb`, because the version
|
|
113
|
+
* they're on is pinned by their own package.json. Those callers need the
|
|
114
|
+
* fact, not the sentence.
|
|
115
|
+
*
|
|
116
|
+
* Caching and the timeout live here rather than at the call site so every
|
|
117
|
+
* lookup gets them. `update.js` previously had its own copy with neither: an
|
|
118
|
+
* unbounded `await fetch()` before the command did any work, which turns a
|
|
119
|
+
* flaky network into a hang on a routine verb.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {number} [opts.timeoutMs=1500] Network cap. Slow/offline returns
|
|
123
|
+
* whatever the cache holds rather than blocking.
|
|
124
|
+
* @param {boolean} [opts.allowNetwork=true] False = cache-only. Pass false
|
|
125
|
+
* for non-TTY callers; scripts must stay fast and offline-safe (the same
|
|
126
|
+
* convention `--version` follows).
|
|
127
|
+
* @param {number} [opts.maxAgeMs] How old a cache entry may be before a
|
|
128
|
+
* refetch is attempted. Defaults to the module's 1-day interval.
|
|
129
|
+
* @returns {Promise<string|null>} The latest version, or null if unknown.
|
|
130
|
+
*/
|
|
131
|
+
export async function getLatestVersion({
|
|
132
|
+
timeoutMs = 1500,
|
|
133
|
+
allowNetwork = true,
|
|
134
|
+
maxAgeMs = CHECK_INTERVAL,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const state = readState()
|
|
137
|
+
const cached = state.latestVersion || null
|
|
138
|
+
const fresh = state.lastCheck && (Date.now() - state.lastCheck) < maxAgeMs
|
|
139
|
+
if (fresh && cached) return cached
|
|
140
|
+
// A stale cache entry still beats nothing for an advisory, so it's the
|
|
141
|
+
// fallback for every failure path below rather than a hard null.
|
|
142
|
+
if (!allowNetwork) return cached
|
|
143
|
+
|
|
144
|
+
const controller = new AbortController()
|
|
145
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch('https://registry.npmjs.org/uniweb/latest', { signal: controller.signal })
|
|
148
|
+
if (!res.ok) return cached
|
|
149
|
+
const data = await res.json()
|
|
150
|
+
const latest = data?.version || null
|
|
151
|
+
if (!latest) return cached
|
|
152
|
+
writeState({ lastCheck: Date.now(), latestVersion: latest })
|
|
153
|
+
return latest
|
|
154
|
+
} catch {
|
|
155
|
+
return cached
|
|
156
|
+
} finally {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
/**
|
|
107
162
|
* Fetch the latest version (with a tight timeout) and print a notice if
|
|
108
163
|
* a newer version is found. Updates the on-disk cache as a side effect
|