uniweb 0.13.17 → 0.14.0

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 (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +15 -13
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
@@ -56,7 +56,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
56
56
  return {
57
57
  dir: info.path,
58
58
  scopedName: foundationScopedName(info.path),
59
- version: readPkgField(info.path, 'version'),
59
+ version: readPkgField(info.path, 'version')
60
60
  }
61
61
  }
62
62
 
@@ -79,7 +79,10 @@ function foundationScopedName(dir) {
79
79
 
80
80
  function readPkgField(dir, field) {
81
81
  try {
82
- return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.[field] || null
82
+ return (
83
+ JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.[field] ||
84
+ null
85
+ )
83
86
  } catch {
84
87
  return null
85
88
  }
@@ -120,7 +123,16 @@ function forwardedFlags(args) {
120
123
  * when the site already references a registry ref / URL (no override needed)
121
124
  * or no scoped ref can be formed.
122
125
  */
123
- export async function bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin, dryRun = false }) {
126
+ export async function bringFoundationAlong({
127
+ client,
128
+ siteDir,
129
+ siteYml,
130
+ args,
131
+ say,
132
+ confirm,
133
+ cliBin,
134
+ dryRun = false
135
+ }) {
124
136
  const local = resolveLocalFoundation(siteDir, siteYml)
125
137
  if (!local) {
126
138
  // Published registry ref / URL — the catalog (or the URL host) already
@@ -129,8 +141,14 @@ export async function bringFoundationAlong({ client, siteDir, siteYml, args, say
129
141
  return { released: false, proceed: true, ref: null }
130
142
  }
131
143
 
132
- const label = local.scopedName || local.version ? `${local.scopedName || 'foundation'}${local.version ? `@${local.version}` : ''}` : 'the local foundation'
133
- const skipPrompts = args.includes('--yes') || args.includes('--force') || args.includes('--no-verify')
144
+ const label =
145
+ local.scopedName || local.version
146
+ ? `${local.scopedName || 'foundation'}${local.version ? `@${local.version}` : ''}`
147
+ : 'the local foundation'
148
+ const skipPrompts =
149
+ args.includes('--yes') ||
150
+ args.includes('--force') ||
151
+ args.includes('--no-verify')
134
152
 
135
153
  // The pinned ref to stamp on the pushed site — read at RETURN time (after any
136
154
  // release), so it reflects the released version + the scope register derived.
@@ -144,17 +162,25 @@ export async function bringFoundationAlong({ client, siteDir, siteYml, args, say
144
162
  // Dry-run reports the intent WITHOUT touching the network — it must not force
145
163
  // a login (the digest read is auth-gated). The real run does the compare.
146
164
  if (dryRun) {
147
- say.dim(`Foundation : ${label} — local; would release if changed or not yet registered`)
165
+ say.dim(
166
+ `Foundation : ${label} — local; would release if changed or not yet registered`
167
+ )
148
168
  return { released: false, proceed: true, ref: null }
149
169
  }
150
170
 
151
171
  // Ask the catalog what it has. Null → not registered (or the backend can't
152
172
  // answer / no scoped name to look up) → release.
153
- const reg = local.scopedName ? await client.readFoundationLatest(local.scopedName) : null
173
+ const reg = local.scopedName
174
+ ? await client.readFoundationLatest(local.scopedName)
175
+ : null
154
176
 
155
177
  if (!reg) {
156
178
  say.info(`Releasing the foundation ${label} (not yet registered)…`)
157
- return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
179
+ return {
180
+ released: releaseFoundation(local, args, cliBin, say),
181
+ proceed: true,
182
+ ref: pinnedRef()
183
+ }
158
184
  }
159
185
 
160
186
  // Registered — fingerprint the local build and compare. Build first so the
@@ -163,42 +189,71 @@ export async function bringFoundationAlong({ client, siteDir, siteYml, args, say
163
189
  const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
164
190
 
165
191
  if (reg.digest && localDigest && reg.digest === localDigest) {
166
- say.dim(`Foundation : ${label} — unchanged since release (digest matches); nothing to release.`)
192
+ say.dim(
193
+ `Foundation : ${label} — unchanged since release (digest matches); nothing to release.`
194
+ )
167
195
  return { released: false, proceed: true, ref: pinnedRef() }
168
196
  }
169
197
 
170
198
  // A different version locally → a new version to release.
171
199
  if (local.version && local.version !== reg.latest_version) {
172
- say.info(`Releasing the foundation ${label} (new version; registered latest is ${reg.latest_version})…`)
173
- return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
200
+ say.info(
201
+ `Releasing the foundation ${label} (new version; registered latest is ${reg.latest_version})…`
202
+ )
203
+ return {
204
+ released: releaseFoundation(local, args, cliBin, say),
205
+ proceed: true,
206
+ ref: pinnedRef()
207
+ }
174
208
  }
175
209
 
176
210
  // Same version, but the digest differs or the backend can't confirm it.
177
211
  if (!reg.digest) {
178
212
  // Degrade: the backend doesn't return the stored digest yet, so we can't
179
213
  // be sure the registered version matches local. Offer to re-deliver.
180
- say.warn(`Can't verify the registered ${label} matches your local copy (backend returned no digest).`)
214
+ say.warn(
215
+ `Can't verify the registered ${label} matches your local copy (backend returned no digest).`
216
+ )
181
217
  if (skipPrompts || isNonInteractive(args)) {
182
- say.dim('Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to publish a change.')
218
+ say.dim(
219
+ 'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to publish a change.'
220
+ )
183
221
  return { released: false, proceed: true, ref: pinnedRef() }
184
222
  }
185
- const reRelease = await confirm(`Re-release ${label} to be sure its code is current?`, false)
186
- if (reRelease) return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
223
+ const reRelease = await confirm(
224
+ `Re-release ${label} to be sure its code is current?`,
225
+ false
226
+ )
227
+ if (reRelease)
228
+ return {
229
+ released: releaseFoundation(local, args, cliBin, say),
230
+ proceed: true,
231
+ ref: pinnedRef()
232
+ }
187
233
  return { released: false, proceed: true, ref: pinnedRef() }
188
234
  }
189
235
 
190
236
  // Case 3 (§4): the foundation was edited but the version wasn't bumped. The
191
237
  // registered version is immutable, so we never silently ship the old code —
192
238
  // the deliberate release gate is a version bump (§3.1).
193
- say.warn(`Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`)
194
- say.dim('A registered version is immutable. Bump the foundation\'s version to release the change, then re-run `uniweb publish`.')
239
+ say.warn(
240
+ `Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`
241
+ )
242
+ say.dim(
243
+ "A registered version is immutable. Bump the foundation's version to release the change, then re-run `uniweb publish`."
244
+ )
195
245
  if (skipPrompts || isNonInteractive(args)) {
196
246
  say.dim(`Proceeding with the already-registered ${reg.latest_version}.`)
197
247
  return { released: false, proceed: true, ref: pinnedRef() }
198
248
  }
199
- const proceed = await confirm(`Publish with the already-registered ${reg.latest_version} anyway?`, false)
249
+ const proceed = await confirm(
250
+ `Publish with the already-registered ${reg.latest_version} anyway?`,
251
+ false
252
+ )
200
253
  if (!proceed) {
201
- say.info('Aborted — bump the foundation version, then re-run `uniweb publish`.')
254
+ say.info(
255
+ 'Aborted — bump the foundation version, then re-run `uniweb publish`.'
256
+ )
202
257
  return { released: false, proceed: false, ref: null }
203
258
  }
204
259
  return { released: false, proceed: true, ref: pinnedRef() }
@@ -210,7 +265,7 @@ function buildFoundation(local, cliBin) {
210
265
  execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
211
266
  cwd: local.dir,
212
267
  stdio: 'inherit',
213
- env: process.env,
268
+ env: process.env
214
269
  })
215
270
  }
216
271
 
@@ -222,7 +277,7 @@ function releaseFoundation(local, args, cliBin, say) {
222
277
  execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
223
278
  cwd: local.dir,
224
279
  stdio: 'inherit',
225
- env: process.env,
280
+ env: process.env
226
281
  })
227
282
  console.log('')
228
283
  return true
@@ -37,13 +37,20 @@ function withParams(url, params) {
37
37
  *
38
38
  * @param {object} o
39
39
  * @param {import('./client.js').BackendClient} o.client
40
- * @param {string|null} o.uuid - the site-content uuid (null before the first push mints it)
40
+ * @param {string|null} o.uuid - the site-content uuid (null only on a dry run; a
41
+ * real publish creates the site before anything uploads, so it is set by here)
41
42
  * @param {string[]} o.args
42
43
  * @param {object} o.say - { ok, info, warn, err, dim } reporters
43
44
  * @param {boolean} [o.dryRun]
44
45
  * @returns {Promise<{ proceed: boolean }>} proceed:false → the caller aborts go-live.
45
46
  */
46
- export async function settlePaymentIfNeeded({ client, uuid, args, say, dryRun = false }) {
47
+ export async function settlePaymentIfNeeded({
48
+ client,
49
+ uuid,
50
+ args,
51
+ say,
52
+ dryRun = false
53
+ }) {
47
54
  // No uuid yet (a first publish mints it on push) → nothing to check here; the
48
55
  // post-push go-live is the moment the backend gates on payment.
49
56
  if (!uuid) return { proceed: true }
@@ -51,17 +58,22 @@ export async function settlePaymentIfNeeded({ client, uuid, args, say, dryRun =
51
58
  // Dry-run reports the intent WITHOUT touching the network — the can-go-live
52
59
  // read is auth-gated and must not force a login on a dry-run.
53
60
  if (dryRun) {
54
- say.dim(`Payment : would check whether go-live needs payment for ${uuid}`)
61
+ say.dim(
62
+ `Payment : would check whether go-live needs payment for ${uuid}`
63
+ )
55
64
  return { proceed: true }
56
65
  }
57
66
 
58
67
  const verdict = await client.canGoLive(uuid)
59
68
  // Degrade (no route) or already-paid → proceed.
60
- if (!verdict || verdict.ok || !verdict.payment_required) return { proceed: true }
69
+ if (!verdict || verdict.ok || !verdict.payment_required)
70
+ return { proceed: true }
61
71
 
62
72
  const checkoutUrl = verdict.checkout_url
63
73
  if (!checkoutUrl) {
64
- say.warn('The backend reports payment is required but returned no checkout URL — proceeding.')
74
+ say.warn(
75
+ 'The backend reports payment is required but returned no checkout URL — proceeding.'
76
+ )
65
77
  return { proceed: true }
66
78
  }
67
79
 
@@ -71,7 +83,9 @@ export async function settlePaymentIfNeeded({ client, uuid, args, say, dryRun =
71
83
  }
72
84
 
73
85
  if (isNonInteractive(args)) {
74
- say.err('Payment is required to publish this site, and the CLI is non-interactive.')
86
+ say.err(
87
+ 'Payment is required to publish this site, and the CLI is non-interactive.'
88
+ )
75
89
  say.dim(`Complete it in a browser, then re-run: ${checkoutUrl}`)
76
90
  return { proceed: false }
77
91
  }
@@ -83,17 +97,22 @@ export async function settlePaymentIfNeeded({ client, uuid, args, say, dryRun =
83
97
  try {
84
98
  await awaitBrowserCallback({
85
99
  buildUrl: (redirectUri) =>
86
- withParams(checkoutUrl, { redirect_uri: redirectUri, state, wait_token: verdict.wait_token }),
100
+ withParams(checkoutUrl, {
101
+ redirect_uri: redirectUri,
102
+ state,
103
+ wait_token: verdict.wait_token
104
+ }),
87
105
  validate: (params) => {
88
106
  if (params.get('error')) return { error: params.get('error') }
89
- if (params.get('state') !== state) return { error: 'state mismatch — please retry.' }
107
+ if (params.get('state') !== state)
108
+ return { error: 'state mismatch — please retry.' }
90
109
  return { value: true } // ok=1 / any non-error return = the app settled with the backend
91
110
  },
92
111
  openingLabel: 'Opening uniweb.app to complete payment…',
93
112
  waitingLabel: 'Waiting for payment to complete (5 min)…',
94
113
  timeoutMs: 5 * 60 * 1000,
95
114
  okTitle: 'Payment complete',
96
- errTitle: 'Payment failed',
115
+ errTitle: 'Payment failed'
97
116
  })
98
117
  } catch (err) {
99
118
  say.err(`Payment was not completed: ${err.message}`)
@@ -23,7 +23,10 @@ import { contentTypeFor } from '../utils/code-upload.js'
23
23
  * @param {object} client - BackendClient (origin + uploadSiteAssets + discover)
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
- * @param {{ onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
26
+ * @param {{ siteUuid?: string|null, onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
27
+ * `siteUuid` is the owner the uploaded bytes are charged to. Callers create the
28
+ * site before uploading precisely so this is set — an unowned upload is charged
29
+ * and cannot be freed, because freeing means deleting the owning entity.
27
30
  * @returns {Promise<{ map: Record<string,string>, missing: string[], failed: Array<{path:string,status:number,detail?:string}> }>}
28
31
  * `map` is ref → serve URL for refs that resolved AND uploaded. The two failure
29
32
  * kinds are reported SEPARATELY because callers must treat them differently:
@@ -32,7 +35,12 @@ import { contentTypeFor } from '../utils/code-upload.js'
32
35
  * store — a transport or QUOTA refusal, and shipping content that still points at
33
36
  * the local path would publish a broken image while only warning about it.
34
37
  */
35
- export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn } = {}) {
38
+ export async function uploadSiteMedia(
39
+ client,
40
+ siteDir,
41
+ refs,
42
+ { siteUuid = null, onProgress, warn } = {}
43
+ ) {
36
44
  if (!refs?.length) return { map: {}, missing: [], failed: [] }
37
45
 
38
46
  const files = []
@@ -51,40 +59,160 @@ export async function uploadSiteMedia(client, siteDir, refs, { onProgress, warn
51
59
  size: bytes.length,
52
60
  sha256: createHash('sha256').update(bytes).digest('hex'),
53
61
  localUrl: ref, // the rewrite key — the original content ref
54
- diskPath: resolved,
62
+ diskPath: resolved
55
63
  })
56
64
  }
57
65
  if (!files.length) return { map: {}, missing, failed: [] }
58
66
 
59
- const result = await client.uploadSiteAssets({ files, onProgress })
67
+ const result = await client.uploadSiteAssets({ files, siteUuid, onProgress })
60
68
  const failed = result.failed || []
61
- for (const f of failed) warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
69
+ for (const f of failed)
70
+ warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
62
71
 
63
72
  const config = await client.discover()
64
73
  const map = {}
65
74
  for (const ref of refs) {
66
75
  const entry = result.assetsByLocalUrl[ref]
67
- if (entry) map[ref] = entry.serveUrl || buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
76
+ if (entry)
77
+ map[ref] =
78
+ entry.serveUrl ||
79
+ buildAssetUrl(client.origin, config.assetBase, entry.id, entry.ext)
68
80
  }
69
81
  return { map, missing, failed }
70
82
  }
71
83
 
84
+ // ─── Asset-plan refusals ──────────────────────────────────────────────────────
85
+ //
86
+ // There is deliberately NO status-based refusal predicate here, and re-adding
87
+ // one would be a mistake. None of the statuses such a heuristic would match
88
+ // means what it looks like —
89
+ // 507 reserved for `storage_quota_exceeded`, but status alone cannot tell it
90
+ // from any other 507, and carries none of the numbers a user needs;
91
+ // 402 never from assets — it is the publish billing-consent gate, so matching it
92
+ // labels a billing refusal a storage problem and tells the user to free space
93
+ // they do not need;
94
+ // 413 comes from the upload PUT, which collects into `failed[]` and never throws,
95
+ // so it cannot reach a catch around the plan.
96
+ // The plan's caps (64 MiB/file, 512 MiB/plan, 1024 files) refuse with `400`, which
97
+ // a 402/413/507 predicate never matched at all.
98
+ //
99
+ // Branch on `reason` instead — never on status, never on `detail` (prose, subject to
100
+ // rewording). Same house style as the push-staleness `reason: "stale_base"` in
101
+ // site-sync.js.
102
+
103
+ const KIB = 1024
104
+ function humanBytes(n) {
105
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
106
+ if (n < KIB) return `${n} B`
107
+ const units = ['KiB', 'MiB', 'GiB', 'TiB']
108
+ let v = n / KIB
109
+ let i = 0
110
+ while (v >= KIB && i < units.length - 1) {
111
+ v /= KIB
112
+ i++
113
+ }
114
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
115
+ }
116
+
117
+ // Append `label: <bytes>` when the value is a usable number. A refusal that omits an
118
+ // extra still produces a useful message — never print `undefined` at a user.
119
+ function pushBytes(lines, label, n) {
120
+ const h = humanBytes(n)
121
+ if (h) lines.push(` ${label}: ${h}`)
122
+ }
123
+
72
124
  /**
73
- * Is this asset-lane error the backend refusing on storage grounds?
125
+ * Turn an asset-plan refusal into user-facing lines, or null when this is not a
126
+ * refusal we recognise (including every refusal shipped today, which is still
127
+ * prose — the typed `reason` values are agreed but NOT YET EMITTED). Null means
128
+ * "fall through to the generic error", so this degrades rather than swallowing.
74
129
  *
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).
130
+ * Two wording rules are load-bearing and come from the ratified accounting model,
131
+ * not from taste:
79
132
  *
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.
133
+ * 1. **Never size a storage refusal from what was transferred.** `needed_bytes` is
134
+ * "assets not yet on this workspace's books", which can be non-zero with ZERO
135
+ * transfers another workspace already uploaded the same bytes, so they come
136
+ * back `present: true`, never PUT, and are charged here anyway. A push can print
137
+ * no `↑` line at all and still be refused, so the message must talk about assets
138
+ * new to the workspace, never "the files being uploaded".
139
+ * 2. **Never advise removing an image to free space.** Freeing is
140
+ * entity-deletion-granular: editing an image out of live content frees nothing.
141
+ * Only deleting the entity (or the site) it was uploaded for returns quota.
142
+ * The predicate this replaced said "remove or shrink assets, then re-run" —
143
+ * advice that is now simply wrong.
84
144
  *
85
- * @param {Error} err
86
- * @returns {boolean}
145
+ * @param {Error & { problem?: object|null }} err
146
+ * @returns {{ headline: string, notes: string[] } | null}
87
147
  */
88
- export function isStorageRefusal(err) {
89
- return /Asset plan failed: HTTP (402|413|507)\b/.test(err?.message || '')
148
+ export function describeAssetRefusal(err) {
149
+ const reason = err?.problem?.reason
150
+ if (typeof reason !== 'string') return null
151
+ const p = err.problem
152
+ const notes = []
153
+
154
+ switch (reason) {
155
+ case 'storage_quota_exceeded': {
156
+ pushBytes(notes, 'Used', p.used_bytes)
157
+ pushBytes(notes, 'Limit', p.limit_bytes)
158
+ // Rule 1: this is what the workspace must take on, not what would transfer.
159
+ pushBytes(notes, 'This publish adds', p.needed_bytes)
160
+ notes.push(
161
+ 'Assets already on its books cost nothing to re-present, so a change'
162
+ )
163
+ notes.push('that touches only content is never refused here.')
164
+ // Rule 2.
165
+ notes.push(
166
+ 'Quota is returned by deleting the site or entity an asset was uploaded'
167
+ )
168
+ notes.push('for — removing an image from content does not free it.')
169
+ // Rule 3: the allowance belongs to whoever OWNS the site, not to whoever is
170
+ // pushing — an asset is charged to the workspace of the entity it is for, so
171
+ // a push to an org site spends the org's allowance whatever context the
172
+ // pusher acts in. "Your storage" would be wrong for every contributor who is
173
+ // not the owner, and wrong in the direction that sends them looking through
174
+ // their own files for space that was never theirs.
175
+ return {
176
+ headline:
177
+ "Storage quota reached — the site owner's workspace cannot take on more assets.",
178
+ notes
179
+ }
180
+ }
181
+ case 'asset_file_too_large': {
182
+ const path = typeof p.path === 'string' ? p.path : null
183
+ pushBytes(notes, 'Size', p.size_bytes)
184
+ pushBytes(notes, 'Per-file limit', p.limit_bytes)
185
+ return {
186
+ headline: `Asset too large${path ? `: ${path}` : ''}`,
187
+ notes: [...notes, 'Shrink or re-encode the file, then re-run.']
188
+ }
189
+ }
190
+ case 'asset_plan_too_large': {
191
+ pushBytes(notes, 'This publish', p.requested_bytes)
192
+ pushBytes(notes, 'Per-publish limit', p.limit_bytes)
193
+ return {
194
+ headline: 'Too many bytes in one publish.',
195
+ notes: [
196
+ ...notes,
197
+ 'Split the media across publishes, or shrink the largest files.'
198
+ ]
199
+ }
200
+ }
201
+ case 'asset_plan_too_many_files': {
202
+ if (Number.isFinite(p.files)) notes.push(` Files: ${p.files}`)
203
+ if (Number.isFinite(p.limit))
204
+ notes.push(` Per-publish limit: ${p.limit}`)
205
+ return {
206
+ headline: 'Too many asset files in one publish.',
207
+ notes: [...notes, 'Split the media across publishes.']
208
+ }
209
+ }
210
+ default:
211
+ // An unrecognised reason is still more useful than nothing, but we do not
212
+ // invent advice for it — surface it and let the generic detail follow.
213
+ return {
214
+ headline: `Asset upload refused by the backend (${reason}).`,
215
+ notes: []
216
+ }
217
+ }
90
218
  }