uniweb 0.13.16 → 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 +16 -14
  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
@@ -55,7 +55,7 @@
55
55
  import { writeFileSync } from 'node:fs'
56
56
  import { resolve } from 'node:path'
57
57
  import { emitSyncPackages } from '@uniweb/build/uwx'
58
- import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
58
+ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
59
59
  import { BackendClient } from '../backend/client.js'
60
60
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
61
61
  import {
@@ -65,16 +65,26 @@ import {
65
65
  readItemBaseVersions,
66
66
  readItemUuids,
67
67
  ensureItemUuids,
68
- pushSyncPackages,
68
+ ensureSiteExists,
69
+ clearRemoteSyncStateIfUnbound,
70
+ pushSyncPackages
69
71
  } from '../backend/site-sync.js'
70
72
 
71
73
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
72
74
  // helpers from this module — their canonical home is now ../backend/site-sync.js.
73
- export { extractMintedSiteUuid, makeModelResolver } from '../backend/site-sync.js'
75
+ export {
76
+ extractMintedSiteUuid,
77
+ makeModelResolver
78
+ } from '../backend/site-sync.js'
74
79
 
75
80
  const colors = {
76
- reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
77
- red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m',
81
+ reset: '\x1b[0m',
82
+ bright: '\x1b[1m',
83
+ dim: '\x1b[2m',
84
+ red: '\x1b[31m',
85
+ green: '\x1b[32m',
86
+ yellow: '\x1b[33m',
87
+ blue: '\x1b[36m'
78
88
  }
79
89
  const log = console.log
80
90
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
@@ -86,7 +96,8 @@ function flagValue(args, name) {
86
96
  const eq = args.find((a) => a.startsWith(`${name}=`))
87
97
  if (eq) return eq.slice(name.length + 1)
88
98
  const i = args.indexOf(name)
89
- if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
99
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
100
+ return args[i + 1]
90
101
  return null
91
102
  }
92
103
 
@@ -116,7 +127,7 @@ export async function push(args = []) {
116
127
  siteBackend,
117
128
  token: tokenFlag,
118
129
  args,
119
- command: 'Syncing',
130
+ command: 'Syncing'
120
131
  })
121
132
 
122
133
  // Build BOTH directional packages (the producer side). Each carries its own
@@ -140,18 +151,51 @@ export async function push(args = []) {
140
151
  // push exists to avoid. `publish` uploaded and rewrote; push dropped
141
152
  // `localAssets` on the floor.
142
153
  //
143
- // Ordering is deliberate: BEFORE `ensureItemUuids`, which mints uuids on the
144
- // backend. A refusal here then leaves nothing minted and nothing submitted.
154
+ // The upload runs before `ensureItemUuids`, and the reason once recorded here —
155
+ // "BEFORE `ensureItemUuids`, which mints uuids on the backend, so a refusal
156
+ // leaves nothing minted" — is FALSE. `ensureItemUuids` mints nothing: it reads a
157
+ // local cache, and if that is empty it reads `site.yml::$uuid` and returns
158
+ // immediately when there is none (the first-push case). Its only backend call is
159
+ // a GET (`pullSiteContent`); its only write is a local file. So this ordering is
160
+ // not protecting what that comment claimed, and is under review — an upload is a
161
+ // durable, metered write, which makes uploading first the thing that leaves bytes
162
+ // behind when a later step fails.
145
163
  //
146
164
  // The probe emit runs WITHOUT `priorHashes`, so it surfaces every local ref
147
165
  // rather than only the changed ones. That is not waste — the lane is
148
166
  // content-addressed with a `present` skip-list, so unchanged bytes are a no-op
149
167
  // PUT. It is also what makes the storage rule fall out of the mechanism instead
150
- // of needing a special case: a content-only push presents the same plan as last
151
- // time, every file is already present, zero new bytes are requested, and no
152
- // quota check can refuse it. Only a push that genuinely ADDS bytes can be
168
+ // of needing a special case: a content-only push adds no new stored content, so
169
+ // a quota has nothing to refuse. Only a push that genuinely ADDS bytes can be
153
170
  // blocked.
154
171
  //
172
+ // Two claims live here and only ONE is a guarantee, per the backend's asset
173
+ // accounting model:
174
+ // - "moves zero bytes" is the COMMON CASE, not a property. `present` is an
175
+ // optimization: a failed presence probe degrades to all-absent, so unchanged
176
+ // bytes may be re-PUT. It is never a false positive, so skipping stays safe.
177
+ // - "is never refused for storage" IS a property, because metering is
178
+ // IDEMPOTENT per (workspace, asset) and recorded at PLAN time. Presenting an
179
+ // asset already on this workspace's books charges nothing, so a push that
180
+ // changes only content meters nothing and cannot be refused.
181
+ //
182
+ // ⚠️ "unchanged bytes skip the transfer" is TRUE. "…and are therefore FREE" is
183
+ // NOT — do not write it into a message, a summary, or a comment. Every upload is
184
+ // chargeable; deduplication is the backend's storage optimization, not the user's
185
+ // discount, so an asset another workspace already uploaded comes back
186
+ // `present: true`, never PUTs, and is STILL charged to this one. What is free is
187
+ // copying URLs around (duplicating a site, a snapshot, a backup).
188
+ //
189
+ // Consequently `needed_bytes` = "assets not yet on this workspace's books", which
190
+ // can be NON-ZERO with zero transfers — so a push can print no `↑` line at all and
191
+ // still be refused. A refusal message must talk about assets new to the workspace,
192
+ // never "the files being uploaded", and must not size itself from what moved.
193
+ //
194
+ // Two framings were proposed and WITHDRAWN in-channel; do not reintroduce either:
195
+ // "charged on distinct stored content", and "the quota fails open on a
196
+ // presence-probe failure" (retired — `needed_bytes` reads the ledger, not the
197
+ // probe, so the property no longer depends on that error path behaving).
198
+ //
155
199
  // It also has to be this emit that carries `assetRewrite` below: the push cache
156
200
  // stores hashes of the REWRITTEN content, so the emit compared against it must
157
201
  // rewrite too, or every entity reads as changed forever.
@@ -161,7 +205,7 @@ export async function push(args = []) {
161
205
  try {
162
206
  const probe = await emitSyncPackages(siteDir, {
163
207
  ...(foundationDir ? { foundationDir } : {}),
164
- resolveModel: makeModelResolver({ client, offline: false }),
208
+ resolveModel: makeModelResolver({ client, offline: false })
165
209
  })
166
210
  mediaRefs = probe.localAssets || []
167
211
  } catch (err) {
@@ -169,53 +213,89 @@ export async function push(args = []) {
169
213
  return { exitCode: 2 }
170
214
  }
171
215
  if (mediaRefs.length) {
216
+ // The site has to exist before its bytes do — an upload with no owning
217
+ // entity is charged and cannot be freed, because freeing means deleting the
218
+ // owner. A no-op once `$uuid` is set, so only a never-synced site pays.
219
+ const dropped = clearRemoteSyncStateIfUnbound(siteDir)
220
+ if (dropped.length) {
221
+ note(
222
+ `Cleared stale sync state from a previous site (${dropped.join(', ')}).`
223
+ )
224
+ }
225
+ const site = await ensureSiteExists({ client, siteDir, asOrg, note })
226
+ if (!site.uuid) {
227
+ error(`Could not create the site on the backend: ${site.reason}`)
228
+ note('Nothing was uploaded and nothing was charged.')
229
+ return { exitCode: 1 }
230
+ }
172
231
  info('Uploading media…')
173
232
  try {
174
- const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
175
- onProgress: (m) => note(` ${m}`),
176
- warn: (m) => note(`! ${m}`),
177
- })
233
+ const { map, failed } = await uploadSiteMedia(
234
+ client,
235
+ siteDir,
236
+ mediaRefs,
237
+ {
238
+ siteUuid: site.uuid,
239
+ onProgress: (m) => note(` ${m}`),
240
+ warn: (m) => note(`! ${m}`)
241
+ }
242
+ )
178
243
  // Bytes that did not land must not be pushed around: the content would go
179
244
  // up still naming the local path, so the teammate sees the broken image
180
245
  // this whole change exists to prevent, and the only trace is a warning. A
181
246
  // missing FILE is a different thing — already broken before us, warned by
182
247
  // the uploader, and not worth blocking a push over.
183
248
  if (failed.length) {
184
- error(`${failed.length} asset(s) failed to upload — nothing was pushed.`)
249
+ error(
250
+ `${failed.length} asset(s) failed to upload — nothing was pushed.`
251
+ )
185
252
  for (const f of failed) note(` ${f.path} (HTTP ${f.status})`)
186
253
  return { exitCode: 1 }
187
254
  }
188
255
  if (Object.keys(map).length) assetRewrite = map
189
- note(`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`)
256
+ note(
257
+ `${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`
258
+ )
190
259
  } catch (err) {
191
- if (isStorageRefusal(err)) {
192
- error('Storage quota reachedthis push adds media that does not fit.')
193
- note(' A push that changes only content costs no storage and still works.')
194
- note(' Remove or shrink the new assets, or raise the plan limit, then re-run.')
195
- note(` ${err.message}`)
196
- return { exitCode: 1 }
260
+ // Typed plan refusals get their own account. Note the storage one must not
261
+ // be phrased from what moved see describeAssetRefusal's rule 1; a push can
262
+ // print no `↑` line at all and still be refused.
263
+ const refusal = describeAssetRefusal(err)
264
+ if (refusal) {
265
+ error(refusal.headline)
266
+ for (const line of refusal.notes) note(line)
267
+ } else {
268
+ error(`Media upload failed: ${err.message}`)
197
269
  }
198
- error(`Media upload failed: ${err.message}`)
199
270
  return { exitCode: 1 }
200
271
  }
201
272
  }
202
273
  }
203
274
 
204
- const itemUuids = (output || dryRun)
205
- ? readItemUuids(siteDir)
206
- : await ensureItemUuids({ client, siteDir, note })
275
+ const itemUuids =
276
+ output || dryRun
277
+ ? readItemUuids(siteDir)
278
+ : await ensureItemUuids({ client, siteDir, note })
207
279
  let pkg
208
280
  try {
209
281
  pkg = await emitSyncPackages(siteDir, {
210
282
  ...(foundationDir ? { foundationDir } : {}),
211
- resolveModel: makeModelResolver({ client, offline: Boolean(output) || dryRun }),
283
+ resolveModel: makeModelResolver({
284
+ client,
285
+ offline: Boolean(output) || dryRun
286
+ }),
212
287
  priorHashes,
213
288
  sendAll,
214
289
  itemUuids,
215
290
  // Both grains are dropped together by --force: one flag, one meaning,
216
291
  // no partial-force mode.
217
- ...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
218
- ...(assetRewrite ? { assetRewrite } : {}),
292
+ ...(force
293
+ ? {}
294
+ : {
295
+ baseVersions: readBaseVersions(siteDir),
296
+ itemBaseVersions: readItemBaseVersions(siteDir)
297
+ }),
298
+ ...(assetRewrite ? { assetRewrite } : {})
219
299
  })
220
300
  } catch (err) {
221
301
  error(`Could not build the sync package: ${err.message}`)
@@ -225,36 +305,53 @@ export async function push(args = []) {
225
305
  log('')
226
306
  for (const w of warnings) note(`! ${w}`)
227
307
 
228
- const totalEntities = (siteContent?.entityCount || 0) + (collections?.entityCount || 0)
308
+ const totalEntities =
309
+ (siteContent?.entityCount || 0) + (collections?.entityCount || 0)
229
310
 
230
311
  // Nothing changed since the last push — the backend is already up to date.
231
312
  if (totalEntities === 0) {
232
- success(`Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`)
313
+ success(
314
+ `Nothing to push — ${skipped} entit${skipped === 1 ? 'y' : 'ies'} unchanged since the last push.`
315
+ )
233
316
  return { exitCode: 0 }
234
317
  }
235
- if (siteContent) info(`${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`)
318
+ if (siteContent)
319
+ info(
320
+ `${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`
321
+ )
236
322
  if (collections) {
237
323
  const n = collections.entityCount
238
- info(`${colors.bright}collections${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${collections.models.join(', ')}`)
324
+ info(
325
+ `${colors.bright}collections${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${collections.models.join(', ')}`
326
+ )
239
327
  }
240
328
  if (skipped) note(`${skipped} unchanged, skipped`)
241
329
 
242
330
  // Preview paths — no submit, no auth. Two lanes → up to two files / two routes.
243
331
  if (output) {
244
332
  const base = output.replace(/\.uwx$/, '')
245
- if (siteContent) writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
246
- if (collections) writeFileSync(resolve(`${base}.collections.uwx`), collections.buffer)
247
- const lanes = [siteContent && 'site-content', collections && 'collections'].filter(Boolean)
333
+ if (siteContent)
334
+ writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
335
+ if (collections)
336
+ writeFileSync(resolve(`${base}.collections.uwx`), collections.buffer)
337
+ const lanes = [
338
+ siteContent && 'site-content',
339
+ collections && 'collections'
340
+ ].filter(Boolean)
248
341
  success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
249
342
  return { exitCode: 0 }
250
343
  }
251
344
  if (dryRun) {
252
345
  if (siteContent) {
253
346
  const verb = siteContentUuid ? 'update' : 'create'
254
- info(`Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`)
347
+ info(
348
+ `Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`
349
+ )
255
350
  }
256
351
  if (collections) {
257
- info(`Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`)
352
+ info(
353
+ `Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
354
+ )
258
355
  }
259
356
  return { exitCode: 0 }
260
357
  }
@@ -266,7 +363,12 @@ export async function push(args = []) {
266
363
  siteDir,
267
364
  pkg,
268
365
  asOrg,
269
- report: { info, note, error, dim: (s) => `${colors.dim}${s}${colors.reset}` },
366
+ report: {
367
+ info,
368
+ note,
369
+ error,
370
+ dim: (s) => `${colors.dim}${s}${colors.reset}`
371
+ }
270
372
  })
271
373
  if (result.exitCode !== 0) return { exitCode: result.exitCode }
272
374
  success(
@@ -39,19 +39,29 @@ import { join } from 'node:path'
39
39
  import yaml from 'js-yaml'
40
40
 
41
41
  import { resolveSiteDir } from './deploy.js'
42
- import { isGitRepo, hasRemote, pullRemote, headProvenance } from '../utils/git.js'
42
+ import {
43
+ isGitRepo,
44
+ hasRemote,
45
+ pullRemote,
46
+ headProvenance
47
+ } from '../utils/git.js'
43
48
  import { probeUnpushed } from '../backend/site-sync.js'
44
49
 
45
50
  const c = {
46
- reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
47
- cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
51
+ reset: '\x1b[0m',
52
+ bold: '\x1b[1m',
53
+ dim: '\x1b[2m',
54
+ cyan: '\x1b[36m',
55
+ green: '\x1b[32m',
56
+ yellow: '\x1b[33m',
57
+ red: '\x1b[31m'
48
58
  }
49
59
  const say = {
50
60
  ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
51
61
  info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
52
62
  warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
53
63
  err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
54
- dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
64
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
55
65
  }
56
66
 
57
67
  // Forward a flag AND its value to the delegated verb. `--backend http://x` is two
@@ -65,7 +75,8 @@ function collectPassthrough(args, names) {
65
75
  const name = names.find((n) => a === n || a.startsWith(`${n}=`))
66
76
  if (!name) continue
67
77
  out.push(a)
68
- if (a === name && args[i + 1] && !args[i + 1].startsWith('-')) out.push(args[++i])
78
+ if (a === name && args[i + 1] && !args[i + 1].startsWith('-'))
79
+ out.push(args[++i])
69
80
  }
70
81
  return out
71
82
  }
@@ -119,7 +130,11 @@ export async function refresh(args = [], deps = {}) {
119
130
  // order to address them in.
120
131
  return { exitCode: 1 }
121
132
  }
122
- say.dim(r.changed ? 'Took new commits from the remote.' : 'Already up to date with the remote.')
133
+ say.dim(
134
+ r.changed
135
+ ? 'Took new commits from the remote.'
136
+ : 'Already up to date with the remote.'
137
+ )
123
138
  consulted.push('git')
124
139
  }
125
140
 
@@ -133,7 +148,11 @@ export async function refresh(args = [], deps = {}) {
133
148
  const pull = deps.pull || (await import('./pull.js')).pull
134
149
  // `--merge` rather than a plain pull: an author editing a different part of the
135
150
  // same section is not a conflict, and should not be presented as one.
136
- const passthrough = collectPassthrough(args, ['--backend', '--registry', '--token'])
151
+ const passthrough = collectPassthrough(args, [
152
+ '--backend',
153
+ '--registry',
154
+ '--token'
155
+ ])
137
156
  const res = await pull(['--merge', ...passthrough])
138
157
  conflicts = res?.merge?.conflicted?.length ?? 0
139
158
  if (res?.exitCode && !conflicts) {
@@ -151,14 +170,20 @@ export async function refresh(args = [], deps = {}) {
151
170
  for (const s of skipped) say.dim(`Skipped: ${s}`)
152
171
 
153
172
  const git = headProvenance(siteDir)
154
- if (git) say.dim(`Commit : ${git.sha.slice(0, 8)}${git.dirty ? ' (working tree has changes)' : ''}`)
173
+ if (git)
174
+ say.dim(
175
+ `Commit : ${git.sha.slice(0, 8)}${git.dirty ? ' (working tree has changes)' : ''}`
176
+ )
155
177
 
156
178
  // What is still yours to send. The point of a milestone check is knowing both
157
179
  // directions, not just that you took what was waiting.
158
180
  if (!skipBackend && siteContentUuid(siteDir)) {
159
181
  try {
160
182
  const probe = await probeUnpushed(siteDir)
161
- if (probe.changed) say.dim(`Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`)
183
+ if (probe.changed)
184
+ say.dim(
185
+ `Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`
186
+ )
162
187
  } catch {
163
188
  /* the probe is a courtesy; never fail a refresh over it */
164
189
  }
@@ -166,7 +191,9 @@ export async function refresh(args = [], deps = {}) {
166
191
 
167
192
  console.log('')
168
193
  if (conflicts) {
169
- say.err(`${conflicts} file(s) need you to resolve conflicts before pushing.`)
194
+ say.err(
195
+ `${conflicts} file(s) need you to resolve conflicts before pushing.`
196
+ )
170
197
  return { exitCode: 1 }
171
198
  }
172
199
  say.ok('Up to date.')