uniweb 0.13.17 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +7 -7
- package/partials/agents.md +51 -0
- package/src/backend/client.js +173 -40
- package/src/backend/data-bundle.js +15 -3
- package/src/backend/foundation-bring-along.js +76 -21
- package/src/backend/payment-handoff.js +28 -9
- package/src/backend/site-media.js +147 -19
- package/src/backend/site-sync.js +362 -40
- package/src/commands/add.js +575 -273
- package/src/commands/build.js +160 -57
- package/src/commands/clone.js +79 -26
- package/src/commands/content.js +11 -7
- package/src/commands/deploy.js +80 -32
- package/src/commands/dev.js +39 -17
- package/src/commands/docs.js +44 -16
- package/src/commands/doctor.js +338 -82
- package/src/commands/export.js +15 -7
- package/src/commands/handoff.js +6 -2
- package/src/commands/i18n.js +248 -99
- package/src/commands/inspect.js +48 -19
- package/src/commands/invite.js +9 -3
- package/src/commands/org.js +19 -5
- package/src/commands/publish.js +229 -60
- package/src/commands/pull.js +157 -48
- package/src/commands/push.js +144 -42
- package/src/commands/refresh.js +37 -10
- package/src/commands/register.js +235 -64
- package/src/commands/rename.js +126 -46
- package/src/commands/runtime.js +74 -24
- package/src/commands/status.js +48 -15
- package/src/commands/sync.js +7 -2
- package/src/commands/template.js +12 -4
- package/src/commands/update.js +263 -87
- package/src/commands/validate.js +115 -32
- package/src/framework-index.json +15 -13
- package/src/index.js +298 -145
- package/src/templates/fetchers/github.js +7 -7
- package/src/templates/fetchers/npm.js +3 -3
- package/src/templates/fetchers/release.js +21 -18
- package/src/templates/index.js +34 -12
- package/src/templates/processor.js +44 -12
- package/src/templates/resolver.js +42 -15
- package/src/templates/validator.js +14 -7
- package/src/utils/asset-upload.js +90 -22
- package/src/utils/code-upload.js +62 -37
- package/src/utils/config.js +31 -10
- package/src/utils/dep-survey.js +33 -7
- package/src/utils/destination-prompt.js +27 -17
- package/src/utils/git.js +66 -22
- package/src/utils/host-prompt.js +4 -3
- package/src/utils/install-integrity.js +8 -4
- package/src/utils/interactive.js +3 -3
- package/src/utils/json-file.js +6 -2
- package/src/utils/names.js +15 -6
- package/src/utils/placement.js +16 -8
- package/src/utils/registry-auth.js +185 -57
- package/src/utils/registry-orgs.js +142 -53
- package/src/utils/runtime-upload.js +52 -14
- package/src/utils/scaffold.js +55 -14
- package/src/utils/site-content-refs.js +3 -1
- package/src/utils/update-check.js +43 -17
- package/src/utils/workspace.js +13 -7
- package/src/versions.js +18 -7
- 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
|
|
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({
|
|
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 =
|
|
133
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
173
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
186
|
-
|
|
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(
|
|
194
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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({
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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, {
|
|
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)
|
|
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(
|
|
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)
|
|
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)
|
|
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
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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 {
|
|
145
|
+
* @param {Error & { problem?: object|null }} err
|
|
146
|
+
* @returns {{ headline: string, notes: string[] } | null}
|
|
87
147
|
*/
|
|
88
|
-
export function
|
|
89
|
-
|
|
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
|
}
|