uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Upload a site's local media to the backend's content-addressed asset store via the
3
+ * SAME asset lane the data bundle rides, and return a `{ ref → serveUrl }` map for the
4
+ * deploy's second emit (`assetRewrite`) to swap the entity content refs for.
5
+ *
6
+ * Input is the site-root asset refs the producer surfaced in
7
+ * `emitSyncPackages().localAssets` (`/images/hero.png`); `resolveAssetPath` finds the
8
+ * file under the site's `public/` (or `assets/`). A ref whose file is missing is
9
+ * skipped (warned), never a broken serve URL. The serve URL is the backend's canonical
10
+ * `serve_url`, read verbatim — origin-relative forms included; nothing here composes one.
11
+ * Content-addressed like every asset: identical bytes → same id → a re-deploy of
12
+ * unchanged media is a cheap no-op PUT (the lane's `present` skip-list).
13
+ */
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { basename } from 'node:path'
18
+ import { resolveAssetPath } from '@uniweb/build/site'
19
+ import { servedFingerprint } from '@uniweb/build/uwx'
20
+ import { contentTypeFor } from '../utils/code-upload.js'
21
+ import { humanBytes } from '../utils/bytes.js'
22
+
23
+ /**
24
+ * @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
25
+ * lane stopped consulting the capability doc when `assetBase` was removed.
26
+ * @param {string} siteDir - the site root (site-root refs resolve under public/)
27
+ * @param {Array<string|{ ref: string, path: string, contentType?: string }>} refs - site-root local
28
+ * asset refs (`/images/x.png`), or files already located (a file record's)
29
+ * @param {{ siteUuid?: string|null, onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
30
+ * `siteUuid` is the owner the uploaded bytes are charged to. Callers create the
31
+ * site before uploading precisely so this is set — an unowned upload is charged
32
+ * and cannot be freed, because freeing means deleting the owning entity.
33
+ * @returns {Promise<{ map: Record<string,string>, ids: Record<string,{id:string,ext:string}>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
34
+ * `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
35
+ * kinds are reported SEPARATELY because callers must treat them differently:
36
+ * `missing` is a ref with no file under the site — an authoring mistake, already
37
+ * broken before us, worth a warning; `failed` is a ref whose bytes we could not
38
+ * store — a transport or QUOTA refusal, and shipping content that still points at
39
+ * the local path would publish a broken image while only warning about it.
40
+ */
41
+ export async function uploadSiteMedia(
42
+ client,
43
+ siteDir,
44
+ refs,
45
+ { siteUuid = null, onProgress, warn } = {}
46
+ ) {
47
+ if (!refs?.length) return { map: {}, ids: {}, missing: [], failed: [] }
48
+
49
+ const files = []
50
+ const missing = []
51
+ // ⭐ A ref is a site-root media path (`/images/x.png`), resolved under the site — or a file the
52
+ // caller already located, `{ ref, path, contentType }`: a file record's file and its preview, which
53
+ // live in the records directory and declare their own type (`@uniweb/file`, 2026-09-28).
54
+ const keys = []
55
+ for (const item of refs) {
56
+ const ref = typeof item === 'string' ? item : item?.ref
57
+ if (typeof ref !== 'string' || !ref) continue
58
+ keys.push(ref)
59
+ const resolved = typeof item === 'string' ? resolveAssetPath(ref, siteDir, siteDir).resolved : item.path
60
+ if (!resolved || !existsSync(resolved)) {
61
+ warn?.(`local-media: ${ref} not found under the site (skipped)`)
62
+ missing.push(ref)
63
+ continue
64
+ }
65
+ const bytes = readFileSync(resolved)
66
+ files.push({
67
+ path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
68
+ content_type: (typeof item === 'object' && item.contentType) || contentTypeFor(basename(resolved)),
69
+ size: bytes.length,
70
+ sha256: createHash('sha256').update(bytes).digest('hex'),
71
+ localUrl: ref, // the rewrite key — the original content ref
72
+ diskPath: resolved
73
+ })
74
+ }
75
+ if (!files.length) return { map: {}, ids: {}, missing, failed: [] }
76
+
77
+ const result = await client.uploadSiteAssets({ files, siteUuid, onProgress })
78
+ const failed = [...(result.failed || [])]
79
+ for (const f of failed)
80
+ warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
81
+
82
+ const map = {}
83
+ // The identity half, for this backend's asset map in `sync.json`. The plan
84
+ // returns an authoritative `id`+`ext` for every entry INCLUDING a `present: true`
85
+ // dedup skip, so a re-push of unchanged media still records identity without
86
+ // moving bytes — which is what makes the committed map cheap to keep accurate.
87
+ const ids = {}
88
+ for (const ref of keys) {
89
+ const entry = result.assetsByLocalUrl[ref]
90
+ if (!entry) continue
91
+ // `served` — a fingerprint of the address, never the address. It is how a pull
92
+ // recognizes this asset where the reference is a bare string with no room for an
93
+ // id beside it (`info.preview`, `seo.image`, a section param); the header of
94
+ // `@uniweb/build`'s asset-map.js says why a hash and not the URL.
95
+ if (entry.id) {
96
+ ids[ref] = {
97
+ id: entry.id,
98
+ ext: entry.ext || '',
99
+ ...(entry.serveUrl ? { served: servedFingerprint(entry.serveUrl) } : {})
100
+ }
101
+ }
102
+ // The backend's canonical serve URL, READ — never composed. An entry without
103
+ // one is an asset we cannot address, and inventing a location for it is the
104
+ // exact failure this lane exists to avoid: a guessed host is SILENTLY wrong,
105
+ // where a missing one is visibly missing. So it joins `failed` and publish
106
+ // refuses, rather than shipping content pointing somewhere nobody claimed.
107
+ //
108
+ // `serve_url` is part of the asset-plan contract: on EVERY entry, both
109
+ // lanes, including entries reported as already `present`. Reconstruction
110
+ // from a discovered `assetBase` was removed once the backend covered that
111
+ // with a test of its own; it was the last hardcoded cross-deployment
112
+ // constant in the CLI. (Confirmed 2026-08-17.)
113
+ if (!entry.serveUrl) {
114
+ warn?.(`local-media: ${ref} — the asset plan returned no serve_url`)
115
+ failed.push({
116
+ path: ref,
117
+ status: 0,
118
+ detail: 'asset plan entry carried no serve_url'
119
+ })
120
+ continue
121
+ }
122
+ map[ref] = entry.serveUrl
123
+ }
124
+ return { map, ids, missing, failed }
125
+ }
126
+
127
+ // ─── Asset-plan refusals ──────────────────────────────────────────────────────
128
+ //
129
+ // There is deliberately NO status-based refusal predicate here, and re-adding
130
+ // one would be a mistake. None of the statuses such a heuristic would match
131
+ // means what it looks like —
132
+ // 507 reserved for `storage_quota_exceeded`, but status alone cannot tell it
133
+ // from any other 507, and carries none of the numbers a user needs;
134
+ // 402 never from assets — it is the publish billing-consent gate, so matching it
135
+ // labels a billing refusal a storage problem and tells the user to free space
136
+ // they do not need;
137
+ // 413 comes from the upload PUT, which collects into `failed[]` and never throws,
138
+ // so it cannot reach a catch around the plan.
139
+ // The plan's caps (64 MiB/file, 512 MiB/plan, 1024 files) refuse with `400`, which
140
+ // a 402/413/507 predicate never matched at all.
141
+ //
142
+ // Branch on `reason` instead — never on status, never on `detail` (prose, subject to
143
+ // rewording). Same house style as the push-staleness `reason: "stale_base"` in
144
+ // site-sync.js.
145
+
146
+ // Append `label: <bytes>` when the value is a usable number. A refusal that omits an
147
+ // extra still produces a useful message — never print `undefined` at a user.
148
+ function pushBytes(lines, label, n) {
149
+ const h = humanBytes(n)
150
+ if (h) lines.push(` ${label}: ${h}`)
151
+ }
152
+
153
+ /**
154
+ * Turn an asset-plan refusal into user-facing lines, or null when this is not a
155
+ * refusal we recognise. Null means "fall through to the generic error", so this
156
+ * degrades rather than swallowing.
157
+ *
158
+ * ⛔ This comment used to assert that the typed `reason` values were "agreed but
159
+ * NOT YET EMITTED" — an unverified claim about ANOTHER LANE's deployed state,
160
+ * sitting in our source where it read as fact and nobody would think to re-check
161
+ * it. That is the precise shape that cost the backend lane a metering hole (a
162
+ * comment claiming what the app sent, wrong the whole time). What we can say is
163
+ * ours: we branch on `reason` and fall through cleanly when there is none, so
164
+ * this function is correct whether or not any given backend emits one, and no
165
+ * sentence here needs to track what a deployment is running.
166
+ *
167
+ * Two wording rules are load-bearing and come from the ratified accounting model,
168
+ * not from taste:
169
+ *
170
+ * 1. **Never size a storage refusal from what was transferred.** `needed_bytes` is
171
+ * "assets not yet on this workspace's books", which can be non-zero with ZERO
172
+ * transfers — another workspace already uploaded the same bytes, so they come
173
+ * back `present: true`, never PUT, and are charged here anyway. A push can print
174
+ * no `↑` line at all and still be refused, so the message must talk about assets
175
+ * new to the workspace, never "the files being uploaded".
176
+ * 2. **Never advise removing an image to free space.** Freeing is
177
+ * entity-deletion-granular: editing an image out of live content frees nothing.
178
+ * Only deleting the entity (or the site) it was uploaded for returns quota.
179
+ * The predicate this replaced said "remove or shrink assets, then re-run" —
180
+ * advice that is now simply wrong.
181
+ *
182
+ * @param {Error & { problem?: object|null }} err
183
+ * @returns {{ headline: string, notes: string[] } | null}
184
+ */
185
+ export function describeAssetRefusal(err) {
186
+ const reason = err?.problem?.reason
187
+ if (typeof reason !== 'string') return null
188
+ const p = err.problem
189
+ const notes = []
190
+
191
+ switch (reason) {
192
+ case 'storage_quota_exceeded': {
193
+ pushBytes(notes, 'Used', p.used_bytes)
194
+ pushBytes(notes, 'Limit', p.limit_bytes)
195
+ // Rule 1: this is what the workspace must take on, not what would transfer.
196
+ pushBytes(notes, 'This publish adds', p.needed_bytes)
197
+ notes.push(
198
+ 'Assets already on its books cost nothing to re-present, so a change'
199
+ )
200
+ notes.push('that touches only content is never refused here.')
201
+ // Rule 2.
202
+ notes.push(
203
+ 'Quota is returned by deleting the site or entity an asset was uploaded'
204
+ )
205
+ notes.push('for — removing an image from content does not free it.')
206
+ // Rule 3: the allowance belongs to whoever OWNS the site, not to whoever is
207
+ // pushing — an asset is charged to the workspace of the entity it is for, so
208
+ // a push to an org site spends the org's allowance whatever context the
209
+ // pusher acts in. "Your storage" would be wrong for every contributor who is
210
+ // not the owner, and wrong in the direction that sends them looking through
211
+ // their own files for space that was never theirs.
212
+ return {
213
+ headline:
214
+ "Storage quota reached — the site owner's workspace cannot take on more assets.",
215
+ notes
216
+ }
217
+ }
218
+ case 'asset_file_too_large': {
219
+ const path = typeof p.path === 'string' ? p.path : null
220
+ pushBytes(notes, 'Size', p.size_bytes)
221
+ pushBytes(notes, 'Per-file limit', p.limit_bytes)
222
+ return {
223
+ headline: `Asset too large${path ? `: ${path}` : ''}`,
224
+ notes: [...notes, 'Shrink or re-encode the file, then re-run.']
225
+ }
226
+ }
227
+ case 'asset_plan_too_large': {
228
+ pushBytes(notes, 'This publish', p.requested_bytes)
229
+ pushBytes(notes, 'Per-publish limit', p.limit_bytes)
230
+ return {
231
+ headline: 'Too many bytes in one publish.',
232
+ notes: [
233
+ ...notes,
234
+ 'Split the media across publishes, or shrink the largest files.'
235
+ ]
236
+ }
237
+ }
238
+ case 'asset_plan_too_many_files': {
239
+ if (Number.isFinite(p.files)) notes.push(` Files: ${p.files}`)
240
+ if (Number.isFinite(p.limit))
241
+ notes.push(` Per-publish limit: ${p.limit}`)
242
+ return {
243
+ headline: 'Too many asset files in one publish.',
244
+ notes: [...notes, 'Split the media across publishes.']
245
+ }
246
+ }
247
+ default:
248
+ // An unrecognised reason is still more useful than nothing, but we do not
249
+ // invent advice for it — surface it and let the generic detail follow.
250
+ return {
251
+ headline: `Asset upload refused by the backend (${reason}).`,
252
+ notes: []
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What `uniweb dev` asks the backend before previewing a site whose foundation is a catalog ref —
3
+ * every clone.
4
+ *
5
+ * Such a site has no build of its foundation, and a build does not turn a ref into a URL: where a
6
+ * version is served is the backend's to say. So the dev server is handed the backend's answer, read
7
+ * for it here through the site — the route push and pull read the version's section types from
8
+ * (`client.readRegisteredFoundation`) — and passed on in `UNIWEB_PREVIEW` (`@uniweb/build/site`'s
9
+ * `preview.js` has the contract). The backend it names is also where the site's kept media URLs are
10
+ * fetched from.
11
+ *
12
+ * ⛔ Nothing here is kept. The answer lives as long as the dev server: `site.yml` keeps the ref, and
13
+ * no file a push or pull reads gets the URL — the round trip never sees it.
14
+ *
15
+ * ⚠️ It may not be known. Whether a backend tells the CLI where a version is served is the backend's
16
+ * to decide, and may change, so an answer without a location is an ordinary outcome: the preview
17
+ * says so and does not start. Nothing else reads it — push, pull and publish work without it.
18
+ */
19
+ import { readBackendState, parseCatalogRef } from '@uniweb/build/uwx'
20
+ import { BackendClient } from './client.js'
21
+ import { resolveWorkspace } from './workspace.js'
22
+ import { readDeclaredFoundation } from '../utils/install-integrity.js'
23
+ import { syncedBackends } from '../utils/site-identity.js'
24
+
25
+ /**
26
+ * What the dev server of `siteDir` is handed, when its foundation is a catalog ref.
27
+ *
28
+ * @param {object} p
29
+ * @param {string} p.siteDir
30
+ * @param {string[]} [p.args]
31
+ * @param {BackendClient} [p.client] - injected in tests
32
+ * @returns {Promise<null
33
+ * | { preview: { backend: string, foundation: { ref: string, url: string, cssUrl: string|null } } }
34
+ * | { refused: string[] }>}
35
+ * null when there is nothing to ask — the foundation is not a catalog ref
36
+ */
37
+ export async function readSitePreview({ siteDir, args = [], client = null }) {
38
+ const ref = readDeclaredFoundation(siteDir)
39
+ if (!parseCatalogRef(ref)) return null
40
+
41
+ client = client || new BackendClient({ args, command: 'Previewing' })
42
+ const uuid = readBackendState(siteDir, client.origin).site?.uuid
43
+ if (!uuid) {
44
+ const known = syncedBackends(siteDir)
45
+ return {
46
+ refused: [
47
+ `This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
48
+ known.length
49
+ ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --server <url>`
50
+ : `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
51
+ ]
52
+ }
53
+ }
54
+
55
+ const ws = await resolveWorkspace({ client, args })
56
+ if (ws.refused) return { refused: ['A preview reads the site in one workspace, and none is chosen.', ws.reason] }
57
+ client.setWorkspace(ws.workspace, { source: ws.source })
58
+
59
+ const reply = await client.readRegisteredFoundation(uuid, ref)
60
+ const url = typeof reply?.module_url === 'string' && reply.module_url ? reply.module_url : null
61
+ if (!url) {
62
+ return {
63
+ refused: [
64
+ reply
65
+ ? `${client.origin} did not say where ${ref} is served, so this site cannot be previewed here.`
66
+ : `Could not read ${ref} through this site on ${client.origin}, so this site cannot be previewed here.`,
67
+ 'Push, pull and publish do not need it.'
68
+ ]
69
+ }
70
+ }
71
+ return {
72
+ preview: {
73
+ backend: client.origin,
74
+ foundation: { ref, url, cssUrl: typeof reply.css_url === 'string' && reply.css_url ? reply.css_url : null }
75
+ }
76
+ }
77
+ }