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,23 @@
1
+ /**
2
+ * Dep-free reader for the foundation ref the backend populates on a site-content
3
+ * document.
4
+ *
5
+ * Kept free of `@uniweb/build` so it's importable from `clone` (global, runs before a
6
+ * project exists) — the same constraint that keeps utils/placement.js dependency-free.
7
+ *
8
+ * The site-content `$`-document carries, on its `info` brief, the `foundation` ref the
9
+ * backend fills in when it wires a site. The reader is tolerant — a backend field
10
+ * rename should degrade, not crash — and is the single adjust-point if the wire field
11
+ * name settles differently. (The site's `@uniweb/folder` is NOT read here: the backend
12
+ * resolves it from the site-content uuid, so the framework never holds a folder uuid.)
13
+ */
14
+
15
+ /**
16
+ * The site's foundation ref — a URL or our `@ns/name@ver`. Written verbatim into
17
+ * site.yml; the runtime loads it as a federated module.
18
+ */
19
+ export function extractFoundationRef(info = {}, document = {}) {
20
+ return (
21
+ info?.foundation ?? info?.foundation_name ?? document?.foundation ?? null
22
+ )
23
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Is this project a copy of another one in the same workspace?
3
+ *
4
+ * A plain directory copy carries `sync.json`, so the copy holds the ORIGINAL's site
5
+ * on every backend the original synced with — and its first push updates that site.
6
+ * `uniweb forget --all` in the copy is the fix. This is how the CLI notices a copy in
7
+ * which nobody ran it, before the push goes out.
8
+ *
9
+ * ## The signal, and why it is exact
10
+ *
11
+ * **Two directories in one workspace holding the same site uuid on the same
12
+ * backend.** Every create mints a new site, so two directories can only name one site
13
+ * if one got it from the other. Whichever of them pushes, the other is a stale copy
14
+ * of the same site waiting to overwrite it — so refusing is right from both sides.
15
+ *
16
+ * ## Why only within a workspace
17
+ *
18
+ * A teammate's clone holds the same site too, and legitimately: it IS the same
19
+ * project. A clone is a separate checkout, so it is never scanned. The price is the
20
+ * case this cannot see: a copy placed outside the workspace is byte-for-byte a clone,
21
+ * and nothing on disk tells them apart.
22
+ *
23
+ * ## Where it looks
24
+ *
25
+ * The workspace root and two levels below it — where sites live (`site/`, `sites/*`)
26
+ * and where a copy usually lands — plus this site's own siblings, for a site deeper
27
+ * than that. Dot-directories, `node_modules` and `dist` are skipped, and the walk is
28
+ * bounded, so a workspace root found somewhere unexpected costs a few milliseconds,
29
+ * not a disk scan.
30
+ */
31
+
32
+ import { existsSync, readdirSync, realpathSync } from 'node:fs'
33
+ import { dirname, isAbsolute, join, relative } from 'node:path'
34
+ import { findWorkspaceRoot } from './workspace.js'
35
+ import { normalizeOrigin, readSiteIdentity } from './site-identity.js'
36
+
37
+ const SKIP = new Set(['node_modules', 'dist'])
38
+ const MAX_ENTRIES = 5000
39
+
40
+ const real = (p) => {
41
+ try {
42
+ return realpathSync(p)
43
+ } catch {
44
+ return p
45
+ }
46
+ }
47
+
48
+ const within = (dir, root) => {
49
+ const rel = relative(root, dir)
50
+ return !rel.startsWith('..') && !isAbsolute(rel)
51
+ }
52
+
53
+ /** Directories holding a `sync.json`, `depth` levels below `dir` at most. */
54
+ function collect(dir, depth, out, budget) {
55
+ let entries
56
+ try {
57
+ entries = readdirSync(dir, { withFileTypes: true })
58
+ } catch {
59
+ return
60
+ }
61
+ for (const entry of entries) {
62
+ if (--budget.left < 0) return
63
+ if (!entry.isDirectory() || entry.name.startsWith('.') || SKIP.has(entry.name)) continue
64
+ const child = join(dir, entry.name)
65
+ if (existsSync(join(child, 'sync.json'))) out.add(child)
66
+ if (depth > 1) collect(child, depth - 1, out, budget)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The other directories in this workspace that hold this site's site on `origin`.
72
+ *
73
+ * @param {string} siteDir
74
+ * @param {string} origin - the backend a push is about to go to
75
+ * @returns {string[]} absolute paths, sorted; `[]` when the site has no identity on
76
+ * that backend yet (its push will create one), or it is in no workspace
77
+ */
78
+ export function findSiteCopies(siteDir, origin) {
79
+ const key = normalizeOrigin(origin)
80
+ const mine = key ? readSiteIdentity(siteDir, key).uuid : null
81
+ if (!mine) return []
82
+ const root = findWorkspaceRoot(siteDir)
83
+ if (!root) return []
84
+
85
+ const candidates = new Set()
86
+ const budget = { left: MAX_ENTRIES }
87
+ if (existsSync(join(root, 'sync.json'))) candidates.add(root)
88
+ collect(root, 2, candidates, budget)
89
+ const parent = dirname(siteDir)
90
+ if (real(siteDir) !== real(root) && within(parent, root)) collect(parent, 1, candidates, budget)
91
+
92
+ const self = real(siteDir)
93
+ const found = []
94
+ for (const dir of candidates) {
95
+ if (real(dir) === self) continue
96
+ if (readSiteIdentity(dir, key).uuid === mine) found.push(dir)
97
+ }
98
+ return found.sort()
99
+ }
100
+
101
+ /**
102
+ * What to tell the person, as a headline and detail lines — each verb prints them
103
+ * with its own reporter.
104
+ *
105
+ * ⚖️ It cannot say WHICH directory is the copy: the two are identical, which is the
106
+ * whole problem. The person knows, because they made it.
107
+ *
108
+ * @param {string[]} copies - from findSiteCopies
109
+ * @param {string} origin
110
+ * @param {'push'|'publish'} verb
111
+ */
112
+ export function describeSiteCopies(copies, origin, verb) {
113
+ const here = (dir) => relative(process.cwd(), dir) || '.'
114
+ return {
115
+ headline: `Another project in this workspace holds the same site on ${origin}:`,
116
+ lines: [
117
+ ...copies.map((dir) => ` ${here(dir)}`),
118
+ `One of them is a copy of the other, so a ${verb} from either one updates that site.`,
119
+ 'In the copy, run: uniweb forget --all',
120
+ `The copy then ${verb === 'publish' ? 'publishes' : 'pushes'} as a new site. The original needs nothing.`
121
+ ]
122
+ }
123
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Static collection data — the passthrough lane.
3
+ *
4
+ * A site's **schema-less** queries have no entity model, so their compiled
5
+ * `dist/data/**` JSON is delivered as files rather than synced as entities.
6
+ * This plans and uploads that set: one plan call, one object per file, each
7
+ * PUT to the target the backend returns.
8
+ *
9
+ * 1. PLAN — POST {apiBase}/dev/site/data-uploads/{site} with the file list
10
+ * ({ path, content_type, size, sha256 }). Response carries
11
+ * `mode` ('presigned' | 'direct'), `expires_in`, `serve_base`,
12
+ * and one `uploads` entry per file ({ path, method, url, headers }).
13
+ * 2. UPLOAD — PUT each file's bytes to its URL. Order is irrelevant; there is
14
+ * no entry file and no server confirm step.
15
+ *
16
+ * ⭐ **`path` is the SERVING TAIL** (`data/articles.json`), not a bookkeeping
17
+ * key. The file lands where it is served — which is the whole point of this
18
+ * lane, and why it returns no map: nothing has to record where anything went.
19
+ *
20
+ * ## Why this is not the asset lane
21
+ *
22
+ * Assets are **content-addressed, and dedup across the sites of one backend**
23
+ * — so the relation from a site's path to an object is many-to-one and a serve
24
+ * path cannot be a property of the object. *("Global" names that dedup scope; it
25
+ * never meant across backends — see utils/asset-upload.js.)* This lane is **per-site and
26
+ * path-addressed**. Sending static data through the asset store is what forced
27
+ * the old "data ball": one bundled asset the backend had to fetch, parse and
28
+ * fan out, which is the only place these bytes ever transited it.
29
+ *
30
+ * ⇒ **The backend is a PASSTHROUGH here** — it brokers the authorization, not
31
+ * the bytes. Hosting static content is the hosting platform's mission where an
32
+ * edge exists; where there is none (edgeless), the backend serves them itself,
33
+ * and that is the posture working rather than a fallback.
34
+ *
35
+ * ⛔ **Do not compose paths onto `serve_base`.** It is `null` on the presigned
36
+ * arm (prod serving is the delivery tier's URL shape, not the backend's to
37
+ * mint) and the gateway's data route on the direct arm. Nothing in a build
38
+ * needs it — the site's fetcher asks for a site-relative `/data/{path}` at
39
+ * runtime — so it is returned for parity and for a caller that wants to verify
40
+ * an upload landed.
41
+ *
42
+ * Contract ratified with the backend lane, 2026-08-18.
43
+ * **Wired into `publish.js` and shipped in `uniweb` 0.25.3.** Both arms of the
44
+ * endpoint exist (presigned landed 2026-08-18, per Diego).
45
+ *
46
+ * ⚠️ **The presigned arm has never been exercised against a real backend from
47
+ * here.** The branch is one line and unit-tested both ways, but a stub is not a
48
+ * presigning deployment — three claims diverged that way in a single day while
49
+ * this lane was being built. A local `uniwebd` answers `direct`, so proving it
50
+ * needs a presigning deployment and one real publish. **Believed correct, not
51
+ * demonstrated.**
52
+ */
53
+
54
+ import { WORKSPACE_HEADER } from '../backend/client.js'
55
+ import { createHash } from 'node:crypto'
56
+ import { humanBytes } from './bytes.js'
57
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
58
+
59
+ /**
60
+ * Plan + upload a site's static collection data files.
61
+ *
62
+ * @param {object} opts
63
+ * @param {string} opts.apiBase - backend origin
64
+ * @param {string} opts.token - bearer, used on the direct arm only
65
+ * @param {string} opts.siteUuid - the site these files belong to (path segment)
66
+ * @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
67
+ * (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
68
+ * one, which is self-authorizing and must carry nothing it was not signed with
69
+ * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
70
+ * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
71
+ * @param {(m: string) => void} [opts.onProgress]
72
+ * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path:string,status:number,detail?:string}>, serveBase: string|null }>}
73
+ * **No map** — by design. The files are at their serving paths.
74
+ */
75
+ export async function uploadSiteData({
76
+ apiBase,
77
+ token,
78
+ siteUuid,
79
+ workspace = null,
80
+ ball,
81
+ onProgress = () => {}
82
+ }) {
83
+ // A plan made with the header mints BARE PUT urls: the PUT names its workspace with
84
+ // the header like every other request (backend, 2026-09-23).
85
+ const workspaceHeader = workspace ? { [WORKSPACE_HEADER]: workspace } : {}
86
+ // ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
87
+ // being a manifest rather than a stream of files.
88
+ //
89
+ // The backend reconciles a site's data usage against what this plan declares:
90
+ // whatever is not in the manifest is gone, so deleting a collection stops
91
+ // costing on the next publish. ⭐ A plan with zero files is a STATEMENT that
92
+ // there are none; the ABSENCE of a request says nothing at all. Returning
93
+ // early here — which this did until 2026-09-01 — made "the user deleted their
94
+ // last schema-less collection" unexpressible on the wire, so that site kept
95
+ // paying for bytes it no longer served until the whole site was deleted.
96
+ // Nothing looked wrong at either end: no error, no warning, just a request
97
+ // that was never sent.
98
+ //
99
+ // Agreed both sides in channel backend↔framework; the backend's plan
100
+ // route accepted an empty `files` array in the same exchange (it was a 400
101
+ // before, which is what made the omission look like the only option).
102
+ const entries = Object.entries(ball?.data || {})
103
+
104
+ // One plan for the whole set. The per-request file cap counts a plan, so
105
+ // splitting would evade it rather than respect it; if a set ever exceeds it,
106
+ // the refusal is the correct outcome and the cap is the thing to fix.
107
+ const files = entries.map(([relPath, value]) => {
108
+ const bytes = Buffer.from(JSON.stringify(value))
109
+ return {
110
+ // ⚠️ The tail RELATIVE TO THE DATA ROOT — no `data/` prefix.
111
+ //
112
+ // The plan's `serve_base` already ends in `/data/`
113
+ // (`/gateway/site/{site}/data/`), and the runtime asks for
114
+ // `{config.base}/data/{name}.json` (`DATA_URL_PREFIX`). Sending
115
+ // `data/articles.json` would store one level too deep and serve at
116
+ // `…/data/data/articles.json`, which the runtime never requests.
117
+ //
118
+ // ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
119
+ // succeeds, and only a visitor's fetch 404s. Confirmed against the
120
+ // shipped contract's own example (`data/articles.json` with a
121
+ // `/data/`-bearing `serve_base`), which supersedes an earlier
122
+ // parenthetical that showed the prefix.
123
+ path: relPath,
124
+ content_type: 'application/json',
125
+ size: bytes.length,
126
+ sha256: createHash('sha256').update(bytes).digest('hex'),
127
+ bytes
128
+ }
129
+ })
130
+
131
+ const origin = apiBase.replace(/\/$/, '')
132
+ // `{site}` is a PATH segment, not a body field — every sibling on this
133
+ // controller addresses the site that way (`content/push/{site}`,
134
+ // `publish/{site}`, `status/{site}`). The asset lane's body-borne owner is
135
+ // the wrong precedent: that lane is global, this one is per-site.
136
+ const planRes = await fetch(
137
+ `${origin}/dev/site/data-uploads/${encodeURIComponent(siteUuid)}`,
138
+ {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ Authorization: `Bearer ${token}`,
143
+ ...workspaceHeader
144
+ },
145
+ body: JSON.stringify({
146
+ files: files.map(({ path, content_type, size, sha256 }) => ({
147
+ path,
148
+ content_type,
149
+ size,
150
+ sha256
151
+ }))
152
+ })
153
+ }
154
+ )
155
+ if (!planRes.ok) {
156
+ const body = await planRes.text().catch(() => '')
157
+ // The PARSED problem document has to survive the throw. Flattening it into
158
+ // the message is what left the other two doors unable to branch on `reason`
159
+ // and printing raw JSON at users; this lane was the last one still doing it.
160
+ // Callers read `err.problem`; `describeDataRefusal` turns it into lines.
161
+ let problem = null
162
+ if (body) {
163
+ try {
164
+ const parsed = JSON.parse(body)
165
+ if (parsed && typeof parsed === 'object') problem = parsed
166
+ } catch {
167
+ /* not a problem document — prose refusal, or an upstream error page */
168
+ }
169
+ }
170
+ const err = new Error(
171
+ `site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
172
+ )
173
+ err.status = planRes.status
174
+ err.problem = problem
175
+ throw err
176
+ }
177
+
178
+ const plan = await planRes.json()
179
+ const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
180
+ // The one mode-aware bit, same rule as the code and runtime lanes: a direct
181
+ // PUT is a bearer-authed backend route; a presigned URL is self-authorizing
182
+ // and must NOT carry a foreign bearer, which can break signature validation.
183
+ const authHeaders =
184
+ plan.mode === 'presigned'
185
+ ? {}
186
+ : { Authorization: `Bearer ${token}`, ...workspaceHeader }
187
+
188
+ const uploaded = []
189
+ const failed = []
190
+ for (const f of files) {
191
+ const target = targets.get(f.path)
192
+ if (!target) {
193
+ // A file the plan did not answer for is unaddressable. Report it; never
194
+ // invent a location for it.
195
+ failed.push({
196
+ path: f.path,
197
+ status: 0,
198
+ detail: 'no upload target in plan'
199
+ })
200
+ continue
201
+ }
202
+ try {
203
+ // Relative on the direct arm, absolute on presigned — `new URL` resolves
204
+ // both against the origin.
205
+ // ⭐ **Retried.** A single connection-level failure used to fail the whole
206
+ // verb after every other file had already gone up. The PUT is idempotent
207
+ // here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
208
+ // — so repeating it is safe by construction, which is why this opts in to
209
+ // `retryOnStatus` rather than inheriting it.
210
+ //
211
+ // ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
212
+ // to send a whole BODY, and aborting a legitimately slow upload only to
213
+ // retry it is worse than not timing out at all.
214
+ const res = await fetchWithRetry(new URL(target.url, origin), {
215
+ method: target.method || 'PUT',
216
+ headers: {
217
+ 'Content-Type': 'application/json',
218
+ ...(target.headers || {}),
219
+ ...authHeaders
220
+ },
221
+ body: f.bytes
222
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
223
+ if (res.ok) {
224
+ uploaded.push(f.path)
225
+ onProgress(`${f.path}`)
226
+ } else {
227
+ failed.push({ path: f.path, status: res.status })
228
+ }
229
+ } catch (err) {
230
+ failed.push({ path: f.path, status: 0, detail: err.message })
231
+ }
232
+ }
233
+
234
+ return {
235
+ mode: plan.mode || 'direct',
236
+ uploaded,
237
+ failed,
238
+ serveBase: plan.serve_base || null
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Turn a typed data-uploads refusal into user-facing lines, or null when there is
244
+ * no typed `reason` (⇒ fall through to the generic message, degrading rather than
245
+ * swallowing).
246
+ *
247
+ * ⭐ The THIRD door, and the last to get one. `/dev/assets` has
248
+ * `describeAssetRefusal`, `POST /dev/site` has `describeCreateRefusal`, and this
249
+ * lane threw prose with the JSON inlined until 2026-09-01 — which is the failure
250
+ * the other two describers exist to prevent, so leaving it was just an untreated
251
+ * instance of a solved problem.
252
+ *
253
+ * ⛔ Branch on `reason`, never the status: `507` alone cannot be told from any
254
+ * other `507` and carries none of the numbers, and `detail` is prose the backend
255
+ * may reword.
256
+ *
257
+ * ⚖️ **The advice DIVERGES from the asset lane's, and that divergence is the whole
258
+ * reason this is a separate function rather than a reused one.** On the asset lane
259
+ * removing an image frees nothing — freeing is entity-deletion-granular. Here the
260
+ * publish declares the COMPLETE set of schema-less data files every time, so
261
+ * dropping a collection and re-publishing is a real way to stop paying for it.
262
+ * Telling a data user "editing content frees nothing" would be false, and telling
263
+ * an asset user "just remove it" would be worse.
264
+ *
265
+ * @param {Error & { problem?: object|null }} err
266
+ * @returns {{ headline: string, notes: string[] } | null}
267
+ */
268
+ export function describeDataRefusal(err) {
269
+ const p = err?.problem
270
+ const reason = p?.reason
271
+ if (typeof reason !== 'string') return null
272
+
273
+ if (reason === 'storage_quota_exceeded') {
274
+ const notes = []
275
+ const used = humanBytes(p.used_bytes)
276
+ const limit = humanBytes(p.limit_bytes)
277
+ const needed = humanBytes(p.needed_bytes)
278
+ if (used) notes.push(` Used: ${used}`)
279
+ if (limit) notes.push(` Limit: ${limit}`)
280
+ if (needed) notes.push(` This publish adds: ${needed}`)
281
+ notes.push(
282
+ 'Every publish declares the full set of schema-less data files, so removing'
283
+ )
284
+ notes.push(
285
+ 'a collection and re-publishing stops it counting. Deleting a site or entity'
286
+ )
287
+ notes.push('frees space too.')
288
+ return {
289
+ headline:
290
+ "Storage quota reached — the site owner's workspace cannot take on more record data.",
291
+ notes
292
+ }
293
+ }
294
+
295
+ // An unrecognised reason still beats a status dump: name it, and let the
296
+ // backend's own prose follow when it sent any.
297
+ const detail = typeof p.detail === 'string' ? p.detail : ''
298
+ return {
299
+ headline: `Record data upload refused by the backend (${reason}).`,
300
+ notes: detail ? [` ${detail}`] : []
301
+ }
302
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The project's backend-minted identity — read from `sync.json`, never from `site.yml`.
3
+ *
4
+ * ⭐ **`site.yml` holds nothing a backend minted, as of 2026-09-20.** It carried
5
+ * `$uuid`, `$org` and `$backend`; all three moved to
6
+ * `sync.json::backends.<origin>.site`, keyed by the backend that minted them.
7
+ * Spec: `kb/framework/reference/sync-json.md`. Why:
8
+ * `kb/framework/plans/backend-scoped-project-state.md`.
9
+ *
10
+ * ## ⛔ What is GONE, and why nothing replaces it
11
+ *
12
+ * `assertSiteBackendScope` refused a command whose resolved origin disagreed with
13
+ * the project's recorded `$backend`. It existed because ONE `$uuid` sat in
14
+ * `site.yml` with no way to say which backend it was for, so it could be read
15
+ * against the wrong one and sent there.
16
+ *
17
+ * ⭐ **Keyed by origin that is unrepresentable.** A command for backend B reads B's
18
+ * section and finds B's ids or nothing at all. There is no configuration in which
19
+ * A's identity reaches B, so there is no mismatch to detect, no stop to print, and
20
+ * no accepted false positive to live with. The guard is not replaced; the shape it
21
+ * guarded against stopped existing.
22
+ *
23
+ * `recordSiteBackend` and `$backend` went with it: the store's KEY is the scope.
24
+ *
25
+ * ## ⛔ NO STATIC `@uniweb/build` IMPORT
26
+ *
27
+ * This module is reachable from the CLI's startup graph, and `@uniweb/build` is an
28
+ * OPTIONAL PEER — a static import makes `uniweb --version` die with
29
+ * ERR_MODULE_NOT_FOUND on a global install (`test/smoke-startup.test.js` catches it).
30
+ *
31
+ * ⭐ **`sync.json` is plain JSON, so reading it needs no parser and no dependency.**
32
+ * That is the whole reason the format is JSON rather than YAML: `site.yml`'s
33
+ * equivalent needed a hand-rolled dependency-free fallback WRITER to survive the
34
+ * same constraint. Writes go through `@uniweb/build/uwx`'s store, which is the one
35
+ * implementation; this file only reads.
36
+ */
37
+
38
+ import { existsSync, readFileSync } from 'node:fs'
39
+ import { join } from 'node:path'
40
+ import { DEFAULT_BACKEND_ORIGIN } from './config.js'
41
+
42
+ /** A bare origin with no trailing slash, or null when unparseable. */
43
+ export function normalizeOrigin(value) {
44
+ if (typeof value !== 'string' || !value.trim()) return null
45
+ try {
46
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null".
47
+ const u = new URL(value)
48
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ /**
55
+ * `sync.json`'s backends map, or `{}`. Read-only, dependency-free — see the header.
56
+ *
57
+ * ⚠️ A second READER of a format whose writer lives in `@uniweb/build`. Deliberate,
58
+ * and bounded: it parses one object and normalizes its keys. The shape it assumes is
59
+ * exactly what `sync-store.js` writes, and `test/two-backends.test.js` drives both.
60
+ */
61
+ function readBackends(siteDir) {
62
+ const p = join(siteDir, 'sync.json')
63
+ if (!existsSync(p)) return {}
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
66
+ const backends = parsed?.backends
67
+ if (!backends || typeof backends !== 'object' || Array.isArray(backends)) return {}
68
+ const out = {}
69
+ for (const [origin, state] of Object.entries(backends)) {
70
+ const key = normalizeOrigin(origin)
71
+ if (key && state && typeof state === 'object') out[key] = state
72
+ }
73
+ return out
74
+ } catch {
75
+ return {}
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The identity this project holds for ONE backend.
81
+ *
82
+ * @param {string} siteDir
83
+ * @param {string} origin
84
+ * @returns {{ uuid: string|null, org: string|null }}
85
+ */
86
+ export function readSiteIdentity(siteDir, origin) {
87
+ const key = normalizeOrigin(origin)
88
+ const site = key ? readBackends(siteDir)[key]?.site : null
89
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
90
+ return { uuid: str(site?.uuid), org: str(site?.org) }
91
+ }
92
+
93
+ /** Every backend this project has synced with, sorted. */
94
+ export function syncedBackends(siteDir) {
95
+ return Object.keys(readBackends(siteDir)).sort()
96
+ }
97
+
98
+ /**
99
+ * The backends this project HAS synced with, when `origin` is not one of them — or null.
100
+ *
101
+ * Every backend verb goes to the backend the user is logged in to *[Diego, 2026-09-21]*,
102
+ * so it can land where this project has no site while it has one elsewhere. A push
103
+ * there CREATES a second site rather than updating the one the project knows, and the
104
+ * owner question it asks does not say why — this is what lets the verb say it first.
105
+ *
106
+ * @param {string} siteDir
107
+ * @param {string} origin - where the verb resolved to
108
+ * @returns {string[]|null}
109
+ */
110
+ export function syncedElsewhere(siteDir, origin) {
111
+ const key = normalizeOrigin(origin)
112
+ const known = syncedBackends(siteDir)
113
+ if (!key || !known.length || known.includes(key)) return null
114
+ return known
115
+ }
116
+
117
+ /**
118
+ * The heads-up for `syncedElsewhere`, as lines — each verb prints them with its own
119
+ * reporter. Only worth saying when the verb was not TOLD where to go: a backend named with
120
+ * UNIWEB_SERVER is already a decision.
121
+ *
122
+ * ⚖️ Worded for where the verb GOES, not for why: it is the logged-in backend, or — logged
123
+ * in nowhere — the default one, where the login it is about to ask for will be.
124
+ *
125
+ * @param {string[]} known
126
+ * @param {string} origin
127
+ * @param {'push'|'publish'} verb
128
+ */
129
+ export function describeSyncedElsewhere(known, origin, verb) {
130
+ const where = known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`
131
+ return [
132
+ `This project's ${where} — not on ${origin}, where this ${verb} goes.`,
133
+ `It creates a new site there. To ${verb} to ${known.length === 1 ? 'that one' : 'one of those'} instead: uniweb login --server <url>`
134
+ ]
135
+ }
136
+
137
+ export { DEFAULT_BACKEND_ORIGIN }