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,1092 @@
1
+ /**
2
+ * uniweb register — submit a foundation (and the data schemas it renders), or a
3
+ * standalone schemas package (the data schemas alone, no foundation), to the
4
+ * registry as one names-only `.uwx` document (uwx-format.md §5).
5
+ *
6
+ * `uniweb login && uniweb register`. Distinct from `uniweb publish`, which ships a
7
+ * SITE — and releases the site's local foundation by running this command
8
+ * (`backend/foundation-bring-along.js`).
9
+ *
10
+ * If the foundation's `dist/` is missing or stale — no build, a version bump
11
+ * since it, a source file newer than it, no SSR bundle (`foundationNeedsBuild`)
12
+ * — `register` builds it first. Preview paths (`--dry-run`, `-o`) never write to
13
+ * `dist/`; they require a pre-built foundation.
14
+ *
15
+ * Run from a foundation, or from a schemas-only package — a package that exports
16
+ * schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
17
+ * folder. The schemas-only package is auto-detected and submits its data schemas
18
+ * standalone (foundation-less); same flags, `--scope` names them.
19
+ *
20
+ * Usage:
21
+ * uniweb register Build the .uwx, submit it, then deliver
22
+ * the foundation's dist/ code (plan +
23
+ * upload — see utils/code-upload.js)
24
+ * uniweb register --scope @org A foundation whose name is bare: register it under
25
+ * @org (resolves @/x -> @org/x) and write the scope
26
+ * into its name in main.js. Refused when the name
27
+ * already carries another scope — a foundation's
28
+ * scope is the one in its name.
29
+ * A schemas-only package: publish under @org.
30
+ * Default: its package.json "uniweb.scope".
31
+ * uniweb register --schema-only Skip the code delivery (schemas land, no dist upload)
32
+ * uniweb register --dry-run Print the .uwx + the code file plan; submit nothing
33
+ * uniweb register -o foundation.uwx Write the .uwx to a file; submit nothing
34
+ * uniweb register --json Porcelain: ONE compact JSON line on stdout
35
+ * ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
36
+ * all human output to stderr — for scripted callers
37
+ *
38
+ * Endpoint: UNIWEB_SERVER > the backend you are logged in to > the default
39
+ * (~/.uniweb/config.json, else uniweb.app) — resolveBackendOrigin. No backend flag:
40
+ * switching is `uniweb login --server <url>`.
41
+ * Auth (submit only): UNIWEB_TOKEN > the stored session > `uniweb login`.
42
+ */
43
+
44
+ // DEFERRED foundation-registration capabilities (the legacy `uniweb publish` had
45
+ // these; the new backend doesn't yet — captured here so the design intent isn't
46
+ // lost, and the legacy code could be removed). Implement as `register` flags (or
47
+ // backend-side policy) when the need is real:
48
+ //
49
+ // • ACCESS POLICY — legacy `--edit-access open|restricted`. On the old registry
50
+ // this gated who could act on the foundation. Its meaning is
51
+ // unclear for the new model: there is no editing of a foundation's code or
52
+ // schema, so it is most likely a LICENSING / access-control concern (who may
53
+ // use/reference the foundation), not "editing". Revisit as `--access` (or an
54
+ // org/licensing policy on the backend) when foundation licensing lands.
55
+ //
56
+ // • VERSION PROPAGATION — legacy `--propagate`. Opts a newly-registered version
57
+ // into the registry's version-update walk: trusting sites whose policy allows
58
+ // the jump (e.g. auto-patch) adopt it with no rebuild; default was "silent"
59
+ // (stored, nothing moves). Implement once the backend has a
60
+ // version-update/propagation policy; until then every register is silent.
61
+
62
+ import {
63
+ existsSync,
64
+ readFileSync,
65
+ writeFileSync,
66
+ readdirSync,
67
+ statSync
68
+ } from 'node:fs'
69
+ import { execSync } from 'node:child_process'
70
+ import { resolve, join, relative } from 'node:path'
71
+ import prompts from 'prompts'
72
+ import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
73
+ import {
74
+ classifyPackage,
75
+ isSchemasPackage,
76
+ collectStandaloneSchemas,
77
+ readFoundationName,
78
+ checkFoundationName,
79
+ splitFoundationName
80
+ } from '@uniweb/build'
81
+ import {
82
+ suggestFoundationName,
83
+ writeFoundationName
84
+ } from '../utils/foundation-name.js'
85
+ import { readRegistryAuth } from '../utils/registry-auth.js'
86
+ import {
87
+ collectDistFiles,
88
+ computeFoundationDigest,
89
+ readRuntimePin
90
+ } from '../utils/code-upload.js'
91
+ import { deriveScope, publishScope, belongsToScope } from '../utils/registry-orgs.js'
92
+ import { BackendClient } from '../backend/client.js'
93
+ import { resolveWorkspace, scopeOfWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
94
+ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
95
+ import {
96
+ findWorkspaceRoot,
97
+ findFoundations,
98
+ promptSelect
99
+ } from '../utils/workspace.js'
100
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
101
+ import { checkFlags } from '../utils/flag-guard.js'
102
+
103
+ const colors = {
104
+ reset: '\x1b[0m',
105
+ bright: '\x1b[1m',
106
+ dim: '\x1b[2m',
107
+ red: '\x1b[31m',
108
+ green: '\x1b[32m',
109
+ yellow: '\x1b[33m',
110
+ blue: '\x1b[36m'
111
+ }
112
+ // Porcelain (`--json`) mode: stdout carries ONLY the final compact JSON line, so
113
+ // all human/colored output diverts to stderr. `emitJson` writes to the REAL
114
+ // stdout (bypassing the redirect). `jsonMode`/`jsonEmitted`/`lastError` are reset
115
+ // per run by the exported `register` wrapper.
116
+ let jsonMode = false
117
+ let jsonEmitted = false
118
+ let lastError = null
119
+ const log = (...a) => (jsonMode ? console.error(...a) : console.log(...a))
120
+ const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
121
+ const error = (m) => {
122
+ lastError = String(m)
123
+ console.error(`${colors.red}✗${colors.reset} ${m}`)
124
+ }
125
+ const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
126
+ const emitJson = (obj) => {
127
+ jsonEmitted = true
128
+ process.stdout.write(JSON.stringify(obj) + '\n')
129
+ }
130
+
131
+ function flagValue(args, name) {
132
+ const eq = args.find((a) => a.startsWith(`${name}=`))
133
+ if (eq) return eq.slice(name.length + 1)
134
+ const i = args.indexOf(name)
135
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
136
+ return args[i + 1]
137
+ return null
138
+ }
139
+
140
+ // The uniweb CLI version, for the `.uwx` exporter envelope. Safe fallback if the
141
+ // package.json isn't reachable.
142
+ function cliVersion() {
143
+ try {
144
+ return JSON.parse(
145
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
146
+ ).version
147
+ } catch {
148
+ return '0.0.0'
149
+ }
150
+ }
151
+
152
+ // A SCHEMAS-ONLY package's recorded publish org, from its package.json `uniweb.scope`
153
+ // (`{ "uniweb": { "scope": "@acme" } }`) — the default when `--scope` is absent. It
154
+ // has no main.js whose name could carry it. ⛔ A foundation's scope is part of its name
155
+ // (2026-09-22); its `uniweb.scope` is refused by `readFoundationName`.
156
+ function readPkgScope(foundationDir) {
157
+ try {
158
+ const pkg = JSON.parse(
159
+ readFileSync(join(foundationDir, 'package.json'), 'utf8')
160
+ )
161
+ return pkg?.uniweb?.scope || null
162
+ } catch {
163
+ return null
164
+ }
165
+ }
166
+
167
+ // Record the chosen publish scope in a schemas-only package's package.json so it
168
+ // travels with the package (read back by readPkgScope on the next register). Preserves
169
+ // the file's existing JSON style. A foundation's goes into its name instead
170
+ // (`settleFoundationScope`).
171
+ async function writePkgScope(foundationDir, scope) {
172
+ const path = join(foundationDir, 'package.json')
173
+ const pkg = JSON.parse(readFileSync(path, 'utf8'))
174
+ pkg.uniweb = { ...(pkg.uniweb || {}), scope }
175
+ await writeJsonPreservingStyleAsync(path, pkg)
176
+ }
177
+
178
+ // The package's name from its package.json, for display. The standalone schemas
179
+ // register has no foundation `_self` to name, so it labels by package name.
180
+ function readPkgName(dir) {
181
+ try {
182
+ return (
183
+ JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.name || null
184
+ )
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Resolve which foundation to register: the cwd if it's a foundation, else the
192
+ * single foundation in the workspace, else prompt (or error in non-interactive).
193
+ * Mirrors `uniweb publish`.
194
+ */
195
+ async function resolveFoundationDir(args) {
196
+ const cwd = process.cwd()
197
+ if (classifyPackage(cwd) === 'foundation') return cwd
198
+
199
+ const workspaceRoot = findWorkspaceRoot(cwd)
200
+ if (workspaceRoot) {
201
+ const foundations = await findFoundations(workspaceRoot)
202
+ if (foundations.length === 1) return resolve(workspaceRoot, foundations[0])
203
+ if (foundations.length > 1) {
204
+ if (isNonInteractive(args)) {
205
+ error('Multiple foundations found. Run register from the one you mean.')
206
+ for (const f of foundations)
207
+ log(
208
+ ` ${colors.cyan || ''}cd ${f} && ${getCliPrefix()} register${colors.reset}`
209
+ )
210
+ process.exit(1)
211
+ }
212
+ const choice = await promptSelect('Which foundation?', foundations)
213
+ if (!choice) {
214
+ log('\nRegister cancelled.')
215
+ process.exit(0)
216
+ }
217
+ return resolve(workspaceRoot, choice)
218
+ }
219
+ }
220
+
221
+ error(
222
+ 'No foundation found. Run register from a foundation directory or a workspace that has one.'
223
+ )
224
+ process.exit(1)
225
+ }
226
+
227
+ /**
228
+ * The foundation's name, settled before anything is built or sent.
229
+ *
230
+ * A foundation registers as `@org/<name>`, and sites pin it by that name. The name
231
+ * is `main.js`'s `name`, else package.json's — `readFoundationName`, the rule the
232
+ * build reads for the schema this command submits. ⛔ `src` and `foundation` are
233
+ * the folder the code sits in, not the code: a project scaffolded before the name
234
+ * moved to main.js has package name `src`, and every such project in an org would
235
+ * register the same `@org/src`. So a name that cannot register is ASKED FOR here,
236
+ * once, and kept in main.js — or, with nobody to ask, refused with the line to add.
237
+ * Before the build, so a written name is built into the schema like any edit.
238
+ *
239
+ * @param {string} targetDir
240
+ * @param {{ args: string[], isPreview: boolean }} o
241
+ * @returns {Promise<boolean>} false → stop; the reason is printed
242
+ */
243
+ export async function settleFoundationName(targetDir, { args, isPreview }) {
244
+ let read
245
+ try {
246
+ read = await readFoundationName(targetDir)
247
+ } catch (err) {
248
+ error(err.message)
249
+ return false
250
+ }
251
+ const problem = checkFoundationName(read.name)
252
+ if (!problem) return true
253
+
254
+ const where = relative(process.cwd(), read.mainFile) || read.mainFile
255
+ const suggestion = suggestFoundationName(targetDir)
256
+ const reason = `${problem[0].toUpperCase()}${problem.slice(1)}.`
257
+
258
+ // Nobody to ask — a script, a preview (which writes nothing), or --json (whose
259
+ // stdout is a data channel a prompt would corrupt). Name the fix exactly.
260
+ if (isPreview || jsonMode || isNonInteractive(args)) {
261
+ error(`This foundation has no name it can register under. ${reason}`)
262
+ log(` Name it in ${colors.bright}${where}${colors.reset}, in the default export:`)
263
+ log(` ${colors.bright}name: '${suggestion || '<name>'}',${colors.reset}`)
264
+ log(` ${colors.dim}It registers as @<org>/<name>, and sites pin that name.${colors.reset}`)
265
+ return false
266
+ }
267
+
268
+ info(`This foundation needs a name to register under. ${reason}`)
269
+ const { name } = await prompts(
270
+ {
271
+ type: 'text',
272
+ name: 'name',
273
+ message: 'Foundation name (it registers as @<org>/<name>):',
274
+ initial: suggestion || '',
275
+ validate: (v) => checkFoundationName(String(v).trim()) || true
276
+ },
277
+ {
278
+ onCancel: () => {
279
+ log('\nRegister cancelled.')
280
+ process.exit(0)
281
+ }
282
+ }
283
+ )
284
+ const chosen = String(name || '').trim()
285
+ if (!chosen) return false
286
+
287
+ const written = writeFoundationName(read.mainFile, chosen, {
288
+ replace: read.source === 'main.js' ? read.name : null
289
+ })
290
+ if (!written.ok) {
291
+ error(`Could not write the name: ${written.reason}.`)
292
+ log(` Add it to the default export in ${colors.bright}${where}${colors.reset}: name: '${chosen}',`)
293
+ return false
294
+ }
295
+ // Read it back through the build's own rule — the text edit is only a means.
296
+ const again = await readFoundationName(targetDir).catch(() => null)
297
+ if (again?.name !== chosen) {
298
+ error(`${where} now names the foundation '${chosen}', but it still reads as "${again?.name}".`)
299
+ return false
300
+ }
301
+ success(`Named the foundation ${colors.bright}${chosen}${colors.reset} — kept in ${where}.`)
302
+ return true
303
+ }
304
+
305
+ /**
306
+ * Settle the scope a foundation registers under — the one in its name.
307
+ *
308
+ * ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22): `name: '@acme/marketing'` in main.js.
309
+ * A scoped name decides it, and a `--scope` naming another org is refused — which org
310
+ * was meant is not ours to guess, and today's alternative was a split identity (the
311
+ * foundation under one org, its data schemas under the other). A bare name has not
312
+ * registered yet: it takes `--scope`, else one derived from your orgs, and the chosen
313
+ * scope is WRITTEN INTO THE NAME, so it is chosen once and travels with the code.
314
+ * ⛔ Until then it was written to `package.json::uniweb.scope`, which is refused now
315
+ * (`readFoundationName`, naming the line to write).
316
+ *
317
+ * A preview (`--dry-run`, `-o`) writes nothing: a bare name previews under `--scope`,
318
+ * or unscoped.
319
+ *
320
+ * @returns {Promise<{ scope: string|null, source: string|null } | null>}
321
+ * null when refused, the reason printed — no scope chosen included, since a name with
322
+ * no scope cannot register. ⛔ Until 2026-10-06 "no scope chosen" was `cancelled`, and
323
+ * register exited 0 having registered nothing.
324
+ */
325
+ export async function settleFoundationScope(targetDir, { args, isPreview, flagScope, client }) {
326
+ let read
327
+ try {
328
+ read = await readFoundationName(targetDir)
329
+ } catch (err) {
330
+ error(err.message)
331
+ return null
332
+ }
333
+ const where = relative(process.cwd(), read.mainFile) || read.mainFile
334
+ const { scope: nameScope, bare } = splitFoundationName(read.name)
335
+
336
+ if (nameScope) {
337
+ if (flagScope && flagScope !== nameScope) {
338
+ error(
339
+ `${where} names this foundation ${read.name}, so it registers under ${nameScope} — ` +
340
+ `--scope ${flagScope} names another org.`
341
+ )
342
+ log(
343
+ ` ${colors.dim}To register it under ${flagScope}, change its name in ${where}: ` +
344
+ `name: '${flagScope}/${bare}'${colors.reset}`
345
+ )
346
+ return null
347
+ }
348
+ return { scope: nameScope, source: 'main.js' }
349
+ }
350
+
351
+ if (isPreview) return { scope: flagScope, source: flagScope ? '--scope' : null }
352
+
353
+ let scope = flagScope
354
+ let source = flagScope ? '--scope' : null
355
+ if (!scope) {
356
+ scope = await deriveScopeFromLogin(client, args)
357
+ if (!scope) return null
358
+ source = 'login'
359
+ }
360
+
361
+ const scoped = `${scope}/${bare}`
362
+ const written = writeFoundationName(read.mainFile, scoped, {
363
+ replace: read.source === 'main.js' ? read.name : null
364
+ })
365
+ if (!written.ok) {
366
+ error(`Could not write the scope into the foundation's name: ${written.reason}.`)
367
+ log(` Name it in the default export in ${colors.bright}${where}${colors.reset}: name: '${scoped}',`)
368
+ return null
369
+ }
370
+ // Read it back through the build's own rule — the text edit is only a means.
371
+ const again = await readFoundationName(targetDir).catch(() => null)
372
+ if (again?.name !== scoped) {
373
+ error(`${where} now names the foundation '${scoped}', but it still reads as "${again?.name}".`)
374
+ return null
375
+ }
376
+ success(`This foundation registers as ${colors.bright}${scoped}${colors.reset} — kept in ${where}.`)
377
+ return { scope, source }
378
+ }
379
+
380
+ /**
381
+ * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
382
+ * when none was chosen.
383
+ *
384
+ * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
385
+ * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
386
+ * working in `@acme` registers `@acme/…`, which any of its members can release.
387
+ * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
388
+ * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
389
+ * renaming it into a new foundation.
390
+ *
391
+ * When the workspace names no scope — none is chosen, or it is a unit without a handle —
392
+ * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
393
+ * you belong to none, else a pick at a terminal, your personal scope without one.
394
+ */
395
+ async function deriveScopeFromLogin(client, args) {
396
+ const token = await client.token()
397
+ const ws = await resolveWorkspace({ client, args })
398
+ const personal = !ws.refused && ws.source !== 'offline' && ws.workspace === null
399
+ const accountHandle = personal ? (await client.fetchOrgs()).account_handle : null
400
+ const fromWorkspace = scopeOfWorkspace(ws, accountHandle)
401
+ if (fromWorkspace) {
402
+ const named = `${colors.bright}${fromWorkspace}${colors.reset}`
403
+ console.error(
404
+ personal
405
+ ? `Registering under your personal scope ${named} — you work in your personal workspace (${SOURCE_LABEL[ws.source]}). For an org: --scope @org.`
406
+ : `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
407
+ )
408
+ return fromWorkspace
409
+ }
410
+ const sess = await readRegistryAuth(client.origin)
411
+ const derived = await deriveScope({
412
+ apiBase: client.origin,
413
+ token,
414
+ accountHandle: sess?.handle || null,
415
+ args
416
+ })
417
+ return derived ? `@${derived}` : null
418
+ }
419
+
420
+ // Directories that are never foundation source. `dist` is the output we compare
421
+ // against; the rest are tooling. Dotted entries are skipped wholesale.
422
+ const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
423
+
424
+ /**
425
+ * The newest mtime under `dir`, skipping build output and tooling. `0` when the
426
+ * tree is unreadable — which reads as "no source is newer", i.e. it degrades to
427
+ * the pre-existing behaviour rather than forcing a build on an fs error.
428
+ *
429
+ * @param {string} dir
430
+ * @returns {number} epoch ms
431
+ */
432
+ function newestSourceMtime(dir) {
433
+ let newest = 0
434
+ const walk = (d) => {
435
+ let entries
436
+ try {
437
+ entries = readdirSync(d, { withFileTypes: true })
438
+ } catch {
439
+ return
440
+ }
441
+ for (const e of entries) {
442
+ if (e.name.startsWith('.') || NON_SOURCE_DIRS.has(e.name)) continue
443
+ const p = join(d, e.name)
444
+ if (e.isDirectory()) {
445
+ walk(p)
446
+ continue
447
+ }
448
+ try {
449
+ const m = statSync(p).mtimeMs
450
+ if (m > newest) newest = m
451
+ } catch {
452
+ /* unreadable file — ignore */
453
+ }
454
+ }
455
+ }
456
+ walk(dir)
457
+ return newest
458
+ }
459
+
460
+ /**
461
+ * Does the foundation's `dist/` need a (re)build before we can register it?
462
+ *
463
+ * Three staleness signals:
464
+ * - MISSING: no `dist/entry.js` (or the legacy `dist/foundation.js`), or no
465
+ * `dist/meta/schema.json` — nothing built yet.
466
+ * - STALE VERSION: the version baked into `dist/meta/schema.json::_self.version`
467
+ * differs from `package.json::version` — a version bump without a rebuild,
468
+ * so the artifact encodes the OLD version while the register intends the
469
+ * NEW one (we'd otherwise submit a schema whose version disagrees with the
470
+ * code we deliver).
471
+ * - STALE SOURCE: a source file is newer than `dist/entry.js`.
472
+ *
473
+ * ⛔ **The source signal was missing until 2026-08-16, and its absence shipped the
474
+ * PREVIOUS bundle silently.** Edit a component's rendered output, re-run
475
+ * `register` with a `dist/` present, and the version signal says fresh (nothing
476
+ * bumped), the artifact signal says fresh (files exist) — so the old bytes upload
477
+ * and the content digest legitimately does not move. The developer's freshness
478
+ * indicator reports "unchanged" and it is telling the truth about an input that
479
+ * never changed. Found by end-to-end testing against a live backend, which is
480
+ * the only place the two halves meet: a unit test on this predicate agrees with
481
+ * it, and the digest agrees with the bytes.
482
+ *
483
+ * ⚠️ **This header used to claim it "mirrors `uniweb publish`'s build-if-stale".
484
+ * It does not, and that sentence is why nobody looked.** `publish` does not do
485
+ * build-if-stale at all — `backend/foundation-bring-along.js` builds
486
+ * UNCONDITIONALLY before digesting, precisely so the digest reflects current
487
+ * source. So publish was already correct and register was not, while a docblock
488
+ * asserted they matched.
489
+ *
490
+ * ⛔ **And do NOT "fix" this by building unconditionally to match publish.**
491
+ * `publish` builds and then shells out to THIS command
492
+ * (`foundation-bring-along.js` → `execFileSync(cliBin, ['register', …])`), so an
493
+ * unconditional build here makes every publish build twice. The mtime signal is
494
+ * what keeps both paths right: publish's fresh `dist/` is newer than its source,
495
+ * so this correctly skips.
496
+ *
497
+ * Returns `{ needs: false }` or `{ needs: true, reason }`.
498
+ *
499
+ * @param {string} targetDir the foundation directory
500
+ */
501
+ export function foundationNeedsBuild(targetDir) {
502
+ const distDir = join(targetDir, 'dist')
503
+ const schemaPath = join(distDir, 'meta', 'schema.json')
504
+ // @uniweb/build emits dist/entry.js; older builds emitted dist/foundation.js.
505
+ const hasArtifact =
506
+ existsSync(join(distDir, 'entry.js')) ||
507
+ existsSync(join(distDir, 'foundation.js'))
508
+ if (!hasArtifact || !existsSync(schemaPath))
509
+ return { needs: true, reason: 'no dist/ found' }
510
+ // The SSR bundle is required by any host that server-renders, and a dist can
511
+ // lack one for two reasons that both reach here looking complete: it was left
512
+ // by a `uniweb dev` session (which skips that sub-build), or it was built by
513
+ // a toolchain older than @uniweb/build@0.14.25, which did not emit it at all.
514
+ // Shipping either produces a site that renders client-side at HTTP 200 with
515
+ // nothing reporting why, so treat it as stale and rebuild.
516
+ if (!existsSync(join(distDir, 'entry-ssr.js')))
517
+ return { needs: true, reason: 'dist/ has no entry-ssr.js (SSR bundle)' }
518
+ let pkgVersion = null
519
+ try {
520
+ pkgVersion =
521
+ JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf8'))
522
+ ?.version || null
523
+ } catch {
524
+ // No readable package.json version — fall through; a present schema with no
525
+ // version to compare is treated as fresh (the submit path validates names).
526
+ }
527
+ try {
528
+ const peek = JSON.parse(readFileSync(schemaPath, 'utf8'))
529
+ if (
530
+ peek?._self?.version &&
531
+ pkgVersion &&
532
+ peek._self.version !== pkgVersion
533
+ ) {
534
+ return {
535
+ needs: true,
536
+ reason: `package.json version (${pkgVersion}) differs from built schema (${peek._self.version})`
537
+ }
538
+ }
539
+ } catch {
540
+ return { needs: true, reason: 'dist/meta/schema.json could not be parsed' }
541
+ }
542
+ // STALE SOURCE — the signal whose absence shipped the previous bundle. Compared
543
+ // against the built entry rather than the schema: the schema is emitted by a
544
+ // later plugin hook, so it is always the newer of the two and using it would
545
+ // widen the window in which an edit reads as already-built.
546
+ //
547
+ // Ties count as fresh (`>`, not `>=`). A build writes its output after reading
548
+ // its input, so equal mtimes mean a same-millisecond build, not a missed edit —
549
+ // and on a fresh clone, where whole trees share a timestamp, `>=` would rebuild
550
+ // every foundation once for nothing.
551
+ try {
552
+ const entryPath = existsSync(join(distDir, 'entry.js'))
553
+ ? join(distDir, 'entry.js')
554
+ : join(distDir, 'foundation.js')
555
+ const builtAt = statSync(entryPath).mtimeMs
556
+ if (newestSourceMtime(targetDir) > builtAt)
557
+ return { needs: true, reason: 'a source file is newer than dist/entry.js' }
558
+ } catch {
559
+ // Can't compare — leave the other signals to decide rather than forcing a
560
+ // build on an fs error.
561
+ }
562
+ return { needs: false }
563
+ }
564
+
565
+ export async function register(args = []) {
566
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a
567
+ // literal scan, so it silently keeps the default (production, for --backend).
568
+ const badFlag = checkFlags('register', args)
569
+ if (badFlag) {
570
+ error(badFlag.message)
571
+ return { exitCode: 2 }
572
+ }
573
+ jsonMode = args.includes('--json')
574
+ jsonEmitted = false
575
+ lastError = null
576
+ const result = await runRegister(args)
577
+ // Guarantee a porcelain line on stdout for every --json exit: the success path
578
+ // emits its own; here we cover the error / early-return paths so a scripted
579
+ // caller can always JSON.parse(stdout).
580
+ if (jsonMode && !jsonEmitted) {
581
+ emitJson(
582
+ result?.exitCode === 0
583
+ ? { ok: true, entities: [] }
584
+ : {
585
+ ok: false,
586
+ error:
587
+ lastError || `register failed (exit ${result?.exitCode ?? 1})`
588
+ }
589
+ )
590
+ }
591
+ return result
592
+ }
593
+
594
+ async function runRegister(args = []) {
595
+ const dryRun = args.includes('--dry-run')
596
+ const output = flagValue(args, '-o') || flagValue(args, '--output')
597
+ const scopeFlag = flagValue(args, '--scope')
598
+ // The backend you are logged in to (UNIWEB_SERVER overrides it for a script) —
599
+ // resolveBackendOrigin, like every backend command.
600
+ const client = new BackendClient({
601
+ args,
602
+ command: 'Registering'
603
+ })
604
+
605
+ // Target: a schemas-only package (standalone data-schema register) or a
606
+ // foundation (foundation + the schemas it renders). A schemas package is only
607
+ // detected when the cwd isn't a foundation, so the foundation path — including
608
+ // its workspace-scan + prompt (resolveFoundationDir) — is unchanged.
609
+ const standalone = isSchemasPackage(process.cwd())
610
+ const targetDir = standalone
611
+ ? process.cwd()
612
+ : await resolveFoundationDir(args)
613
+
614
+ // Scope. ⭐ A FOUNDATION's is the one in its name (`name: '@acme/marketing'` in
615
+ // main.js), settled below before the build; a bare name takes --scope, else the
616
+ // workspace the command works in (`deriveScopeFromLogin`), and register writes it
617
+ // into the name (2026-09-22). A SCHEMAS-ONLY package has no name to carry one:
618
+ // --scope, else package.json `uniweb.scope`, else (real submit only) the workspace
619
+ // the command works in. Either spelling, `@acme` or `acme`, and from here on the one
620
+ // form: `@acme` (publishScope). ⛔ This said "derived from your orgs" / "from login
621
+ // membership" until 2026-10-06, read as the personal-scope default that was replaced.
622
+ const pkgScope = standalone ? readPkgScope(targetDir) : null
623
+ const givenScope = scopeFlag || pkgScope
624
+ let scope = publishScope(givenScope)
625
+ let scopeSource = scopeFlag
626
+ ? '--scope'
627
+ : pkgScope
628
+ ? 'package.json uniweb.scope'
629
+ : null
630
+ // A scope that was GIVEN but names no org (`--scope @`) is refused, never read as
631
+ // absent: absent derives one from the login, which would register under an org the
632
+ // caller did not name.
633
+ if (givenScope && !scope) {
634
+ error(`Not an org scope: ${givenScope} (from ${scopeSource})`)
635
+ log(` ${colors.dim}Pass --scope @org.${colors.reset}`)
636
+ return { exitCode: 2 }
637
+ }
638
+ const isPreview = !!output || dryRun
639
+
640
+ // Each path supplies a different schema source: the standalone path discovers
641
+ // the package's own schemas; the foundation path reads its built schema.json.
642
+ let schema = null
643
+ let schemas = null
644
+ if (standalone) {
645
+ try {
646
+ schemas = await collectStandaloneSchemas(targetDir)
647
+ } catch (err) {
648
+ error(`Could not read the schemas package: ${err.message}`)
649
+ return { exitCode: 2 }
650
+ }
651
+ if (!schemas || Object.keys(schemas).length === 0) {
652
+ error('No data schemas found in this package.')
653
+ log(
654
+ ` ${colors.dim}Expected a package that exports schemas (getSchema / schemas), or a schemas/ directory of *.yml files.${colors.reset}`
655
+ )
656
+ return { exitCode: 2 }
657
+ }
658
+ } else {
659
+ if (!(await settleFoundationName(targetDir, { args, isPreview }))) {
660
+ return { exitCode: 2 }
661
+ }
662
+ // ⭐ BEFORE THE BUILD, so the build embeds the name the foundation registers as.
663
+ // Settled after it, a scope written into main.js would leave dist/ describing the
664
+ // bare name — stale the moment it was built.
665
+ const settled = await settleFoundationScope(targetDir, {
666
+ args,
667
+ isPreview,
668
+ flagScope: scope,
669
+ client
670
+ })
671
+ if (!settled) return { exitCode: 2 }
672
+ scope = settled.scope
673
+ scopeSource = settled.source
674
+ // Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
675
+ // (re)built before we read its schema. Preview paths
676
+ // (--dry-run / -o) must not write to dist/, so they require a pre-built
677
+ // foundation and say so instead of building.
678
+ const { needs, reason } = foundationNeedsBuild(targetDir)
679
+ if (needs) {
680
+ if (isPreview) {
681
+ error(`No usable build (${reason}).`)
682
+ log(
683
+ ` Build the foundation first: ${colors.bright}uniweb build${colors.reset}`
684
+ )
685
+ return { exitCode: 2 }
686
+ }
687
+ info(`${reason} — building the foundation first …`)
688
+ try {
689
+ execSync('npx uniweb build --target foundation', {
690
+ cwd: targetDir,
691
+ // ⛔ `inherit` wires the DELEGATE's stdout to ours, so under --json the
692
+ // builder's progress lands on stdout ahead of the JSON line and
693
+ // `JSON.parse(stdout)` throws — on exactly the cold path a fresh
694
+ // checkout always takes. register's own output is already redirected to
695
+ // stderr (see `log` above); this child is the one stream it did not own,
696
+ // so --json was porcelain only when a build was NOT needed.
697
+ //
698
+ // fd 2 is our stderr: progress stays visible, stdout stays parseable.
699
+ // Found by a harness that had to work around it by taking the last
700
+ // JSON line off stdout.
701
+ stdio: jsonMode ? ['inherit', 2, 'inherit'] : 'inherit',
702
+ // A step of register: the build's "Share with clients: uniweb register" hint
703
+ // would tell someone running register to run register.
704
+ env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
705
+ })
706
+ } catch (err) {
707
+ error(`Build failed: ${err.message}`)
708
+ return { exitCode: 2 }
709
+ }
710
+ }
711
+ const schemaPath = join(targetDir, 'dist', 'meta', 'schema.json')
712
+ if (!existsSync(schemaPath)) {
713
+ error('No built schema found (dist/meta/schema.json) after build.')
714
+ log(` Build the foundation: ${colors.bright}uniweb build${colors.reset}`)
715
+ return { exitCode: 2 }
716
+ }
717
+ try {
718
+ schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
719
+ } catch (err) {
720
+ error(`Could not read ${schemaPath}: ${err.message}`)
721
+ return { exitCode: 2 }
722
+ }
723
+ }
724
+
725
+ // A schemas-only package with no scope, on a real submit → the workspace you work in,
726
+ // else derived from your orgs (`deriveScopeFromLogin`), and persisted to package.json.
727
+ // (A foundation's was settled before its build — `settleFoundationScope`.)
728
+ if (standalone && !scope && !isPreview) {
729
+ const derived = await deriveScopeFromLogin(client, args)
730
+ if (!derived) return { exitCode: 2 }
731
+ scope = derived
732
+ scopeSource = 'login'
733
+ try {
734
+ await writePkgScope(targetDir, scope)
735
+ info(
736
+ `Saved ${colors.bright}${scope}${colors.reset} as this package's publish scope (package.json).`
737
+ )
738
+ } catch {
739
+ log(
740
+ ` ${colors.dim}(Could not save the scope to package.json — pass --scope ${scope} next time.)${colors.reset}`
741
+ )
742
+ }
743
+ }
744
+
745
+ // Content digest (foundation path only) — the freshness fingerprint over what
746
+ // register ships (shipping-model.md §4.1). Rides in the foundation-schema
747
+ // entity's info.digest; the backend stores it opaque and returns it so
748
+ // publish/status can detect "code changed since release" with no local state.
749
+ const digest = standalone
750
+ ? null
751
+ : computeFoundationDigest(join(targetDir, 'dist'))
752
+
753
+ // Compatibility floor (foundation path only) — the @uniweb/runtime version
754
+ // this build linked against. Rides in info.runtime beside info.digest.
755
+ //
756
+ // Until this shipped, `dist/runtime-pin.json` was written by the build and
757
+ // read by NOTHING, so a floor a foundation stated could never reach anyone
758
+ // able to act on it. It cannot be acted on here either: a site's floor is
759
+ // max() over its primary foundation AND its extensions, and this command
760
+ // publishes one foundation without sight of the others. So the producer
761
+ // states its own floor and whoever resolves a whole site does the arithmetic.
762
+ const runtime = standalone ? null : readRuntimePin(join(targetDir, 'dist'))
763
+
764
+ const exporter = { tool: 'uniweb', version: cliVersion(), instance: 'build' }
765
+ let doc
766
+ try {
767
+ doc = standalone
768
+ ? buildSchemaOnlyPackage({ schemas, scope, exporter })
769
+ : buildRegistryPackage({
770
+ schema,
771
+ foundationDir: targetDir,
772
+ scope,
773
+ exporter,
774
+ digest,
775
+ runtime
776
+ })
777
+ } catch (err) {
778
+ error(`Could not assemble the .uwx: ${err.message}`)
779
+ return { exitCode: 2 }
780
+ }
781
+ const json = JSON.stringify(doc, null, 2)
782
+
783
+ const defined = doc.entities
784
+ .filter((e) => e.model === '@uniweb/data-schema')
785
+ .map((e) => e.name)
786
+ log('')
787
+ if (standalone) {
788
+ info(
789
+ `${colors.bright}${readPkgName(targetDir) || 'schemas'}${colors.reset} ${colors.dim}(schemas-only — no foundation)${colors.reset}`
790
+ )
791
+ } else {
792
+ info(
793
+ `${colors.bright}${schema._self.name}@${schema._self.version}${colors.reset}`
794
+ )
795
+ }
796
+ log(
797
+ ` ${colors.dim}data schemas ${standalone ? 'registered' : 'defined'}: ${defined.length ? defined.join(', ') : '(none)'}${colors.reset}`
798
+ )
799
+ if (scope)
800
+ log(` ${colors.dim}scope: ${scope} (${scopeSource})${colors.reset}`)
801
+ if (digest) log(` ${colors.dim}digest: ${digest}${colors.reset}`)
802
+ if (runtime) log(` ${colors.dim}runtime floor: ${runtime}${colors.reset}`)
803
+
804
+ // Preview paths — no submit, no auth needed.
805
+ if (output) {
806
+ writeFileSync(resolve(output), json)
807
+ success(`Wrote ${output} (${doc.entities.length} entities) — not submitted`)
808
+ return { exitCode: 0 }
809
+ }
810
+ if (dryRun) {
811
+ log('')
812
+ log(json)
813
+ log('')
814
+ info(`Dry run — would submit to ${client.origin}`)
815
+ if (!standalone && !args.includes('--schema-only')) {
816
+ const distFiles = collectDistFiles(join(targetDir, 'dist'))
817
+ log('')
818
+ info(
819
+ `Would then deliver ${distFiles.length} code file(s) (meta/ excluded):`
820
+ )
821
+ for (const f of distFiles) {
822
+ log(
823
+ ` ${colors.dim}${f.path} ${f.size} bytes ${f.content_type}${colors.reset}`
824
+ )
825
+ }
826
+ }
827
+ return { exitCode: 0 }
828
+ }
829
+
830
+ // Submit requires a concrete scope — the registry rejects @/… fail-closed.
831
+ if (!scope) {
832
+ error(
833
+ standalone
834
+ ? 'No publish scope — set "uniweb.scope" in package.json, or pass --scope @org.'
835
+ : "No publish scope — name the foundation '@org/<name>' in main.js, or pass --scope @org."
836
+ )
837
+ log(
838
+ ` ${colors.dim}Without a scope, names stay @/… and the registry rejects them.${colors.reset}`
839
+ )
840
+ return { exitCode: 2 }
841
+ }
842
+ // Submit — the client carries the bearer (UNIWEB_TOKEN › stored
843
+ // session › login), resolved lazily on this first authed call.
844
+ info(`Submitting to ${colors.dim}${client.origin}${colors.reset} …`)
845
+ let res
846
+ try {
847
+ res = await client.register(json)
848
+ } catch (err) {
849
+ error(`Could not reach the registry at ${client.origin}: ${err.message}`)
850
+ log(
851
+ ` ${colors.dim}Is that the backend you meant? Switch with: uniweb login --server <url>${colors.reset}`
852
+ )
853
+ return { exitCode: 2 }
854
+ }
855
+ // Read the response body once: the --json success path needs it for the minted
856
+ // entity ids; the error path shows it.
857
+ const rawBody = await res.text().catch(() => '')
858
+ let parsedBody = null
859
+ try {
860
+ parsedBody = rawBody ? JSON.parse(rawBody) : null
861
+ } catch {
862
+ parsedBody = null
863
+ }
864
+ let alreadyRegistered = false
865
+ if (!res.ok) {
866
+ // Resume path: a registered version is immutable, so re-running after a
867
+ // partial code delivery hits the duplicate rejection here — a STRUCTURED
868
+ // 409 (problem+json, title "Conflict", naming the `version` it refused) — and
869
+ // proceeds to phase 2 (the code-uploads plan authorizes against the REGISTERED
870
+ // version; files it already stores come back `present` and are skipped).
871
+ //
872
+ // ⛔ ONE STATUS, MORE THAN ONE MEANING. A 409 that names no version is another
873
+ // refusal — a data schema changed where the backend holds records of it
874
+ // (`reason: "destructive_republish"`) — and until 2026-09-26 it was read as a
875
+ // resume too: "already registered — resuming code delivery", then a 404 for a
876
+ // version that was never registered, and the refusal's own sentence never shown.
877
+ // A body with no shape to read resumes, as it always did.
878
+ const isDuplicate =
879
+ !standalone && res.status === 409 && (!parsedBody || typeof parsedBody !== 'object' || parsedBody.version !== undefined)
880
+ if (isDuplicate) {
881
+ alreadyRegistered = true
882
+ info(
883
+ `${colors.dim}Schema for this version is already registered — resuming code delivery.${colors.reset}`
884
+ )
885
+ } else {
886
+ // ⭐ NOT A MEMBER OF THE SCOPE IS SAID AS THAT *[Diego, 2026-10-06]*. The registry
887
+ // decides who may release into a scope — its account, or an org's members — and
888
+ // when the orgs read says you are neither, that is the refusal to name. ⛔ Until then
889
+ // a 403 here printed "log in again", the wrong advice for a scope that is not yours.
890
+ // Read to explain a refusal, never to gate one; a 401 is the credential itself.
891
+ const member =
892
+ res.status === 401 ? null : belongsToScope(scope, await client.fetchOrgs().catch(() => null))
893
+ if (member === false) {
894
+ const what = standalone
895
+ ? `data schemas under ${scope}`
896
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info?.name || `a foundation under ${scope}`
897
+ error(`You can't release ${what}: you're not a member of ${scope}.`)
898
+ log(
899
+ ` ${colors.dim}A member of ${scope} can release it. (The registry answered HTTP ${res.status}.)${colors.reset}`
900
+ )
901
+ } else {
902
+ error(
903
+ `Registry rejected the submission: HTTP ${res.status} ${res.statusText}`
904
+ )
905
+ }
906
+ if (member !== false && (res.status === 401 || res.status === 403)) {
907
+ log(
908
+ ` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
909
+ )
910
+ log(
911
+ ` ${colors.dim}Log in again (uniweb login --server <url>), or check UNIWEB_TOKEN — an existing one may be wrong or expired.${colors.reset}`
912
+ )
913
+ }
914
+ // ⭐ **Surface `detail` as a sentence, not as a JSON dump.** The registry
915
+ // answers with problem+json (RFC 9457), and its `detail` is written for a
916
+ // human — it carries the actual next step, which for a rejected
917
+ // re-register is "bump the version". Printing the whole body dimmed put
918
+ // that sentence inside a blob, at the end of a 500-char truncation, in the
919
+ // one moment the reader most needs it.
920
+ //
921
+ // ⚖️ Keyed on the SHAPE, not on any particular code: a new refusal the CLI
922
+ // has never seen explains itself the day the backend ships it, with no
923
+ // release here. That is the property worth having — this branch exists
924
+ // because a 422 the CLI did not know about arrived exactly that way.
925
+ if (parsedBody?.detail) {
926
+ log(` ${parsedBody.detail}`)
927
+ const code = parsedBody.code ?? parsedBody.reason
928
+ if (code) {
929
+ log(` ${colors.dim}(${code})${colors.reset}`)
930
+ }
931
+ } else if (rawBody) {
932
+ log(` ${colors.dim}${rawBody.slice(0, 500)}${colors.reset}`)
933
+ }
934
+ return { exitCode: 1 }
935
+ }
936
+ }
937
+ // The foundation's name as the registry holds it (`@org/name`) — read from the
938
+ // document just submitted, never composed again. See phase 2.
939
+ const registeredName = standalone
940
+ ? null
941
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info
942
+ ?.name
943
+ if (!alreadyRegistered) {
944
+ success(
945
+ standalone
946
+ ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
947
+ : `Registered ${registeredName}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
948
+ )
949
+ }
950
+
951
+ // Phase 2 — deliver the foundation's code (plan → PUT-per-file, entry
952
+ // last; contract: foundation-code-upload.md). Schemas-
953
+ // only packages have no dist; --schema-only skips deliberately.
954
+ if (!standalone && !args.includes('--schema-only')) {
955
+ const distDir = join(targetDir, 'dist')
956
+ // ⭐ The plan authorizes against the REGISTERED (name, version), so the name here
957
+ // is the one phase 1 registered — the `.uwx`'s own `info.name`, where a scoped
958
+ // package name passes through and a bare one gets the scope. ⛔ Not composed a
959
+ // second time: this line composed it from the raw scope until 2026-09-21, so
960
+ // `--scope std` registered `@std/src` and then asked to deliver `std/src`, which
961
+ // the registry refuses. One name, read from the one place it was decided.
962
+ const name = registeredName
963
+ const version = schema._self.version
964
+ info(
965
+ `Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`
966
+ )
967
+ try {
968
+ const result = await client.uploadFoundationCode({
969
+ name,
970
+ version,
971
+ distDir,
972
+ onProgress: (m) => log(` ${colors.dim}${m}${colors.reset}`)
973
+ })
974
+ if (result.failed.length) {
975
+ error(`${result.failed.length} file(s) failed to upload:`)
976
+ for (const f of result.failed) {
977
+ log(
978
+ ` ${colors.red}${f.path}${colors.reset} ${colors.dim}HTTP ${f.status} ${f.detail}${colors.reset}`
979
+ )
980
+ }
981
+ log(
982
+ ` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
983
+ )
984
+ return { exitCode: 1 }
985
+ }
986
+ // "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
987
+ const delivered = result.stored?.length
988
+ ? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
989
+ : `${result.uploaded.length} files`
990
+ if (result.verified === true) {
991
+ // serveBase is guaranteed here — it is what gated the check.
992
+ success(
993
+ `Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
994
+ )
995
+ } else if (result.verified === false) {
996
+ error(
997
+ 'Code uploaded but the entry verification fetch did not match — investigate before using this version.'
998
+ )
999
+ return { exitCode: 1 }
1000
+ } else {
1001
+ success(`Code delivered (${delivered}, ${result.mode} mode)`)
1002
+ // Say WHY nothing was checked, rather than skipping in silence. The CLI
1003
+ // never reconstructs a serve URL (see utils/code-upload.js), so a plan
1004
+ // without `serve_base` means the location is not ours to know — name it
1005
+ // as a backend gap so it surfaces there instead of looking like a pass.
1006
+ if (!result.serveBase) {
1007
+ log(
1008
+ ` ${colors.dim}Not verified: the backend reported no serve location for this version.${colors.reset}`
1009
+ )
1010
+ }
1011
+ }
1012
+ } catch (err) {
1013
+ // ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
1014
+ // declared at a different size (`version_content_changed`). Running the
1015
+ // same command again gets the same answer, so do not suggest it: print
1016
+ // the refusal's own sentence, which says what does help.
1017
+ if (err.status >= 400 && err.status < 500 && err.detail) {
1018
+ error(`Code delivery refused: HTTP ${err.status}`)
1019
+ log(` ${err.detail}`)
1020
+ if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
1021
+ return { exitCode: 1 }
1022
+ }
1023
+ error(`Code delivery failed: ${err.message}`)
1024
+ log(
1025
+ ` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
1026
+ )
1027
+ return { exitCode: 1 }
1028
+ }
1029
+ }
1030
+ // Registering ≠ delivering the file. A registered schema is a content type
1031
+ // authors use in the app; a FOUNDATION build still resolves '@scope/name' from
1032
+ // disk (a package or a routed folder). This is the moment that belief forms —
1033
+ // you just registered it — so correct it here, not in a doc nobody's reading.
1034
+ if (!jsonMode && defined.length > 0 && scope) {
1035
+ log('')
1036
+ info(
1037
+ 'Registered schemas are content types authors can use in the Uniweb app.'
1038
+ )
1039
+ log(
1040
+ ` ${colors.dim}Delivery is separate: a foundation that binds ${scope}/<name> resolves it from${colors.reset}`
1041
+ )
1042
+ log(
1043
+ ` ${colors.dim}disk — an ${scope}/schemas package, or a folder routed in schemas.config.js.${colors.reset}`
1044
+ )
1045
+ }
1046
+
1047
+ if (jsonMode) {
1048
+ // Join my authoritative submitted names with the backend's minted ids. Each
1049
+ // response entry is `{ registered: { name, version, payload_model_uuid, … },
1050
+ // unchanged }` (symmetric on the new-version + unchanged branches); a flat
1051
+ // shape is tolerated as a fallback. Names from doc.entities are the spine, so
1052
+ // the porcelain always reports WHICH names landed even if a field is absent.
1053
+ const minted = {}
1054
+ const addMint = (e) => {
1055
+ if (!e || typeof e !== 'object') return
1056
+ const reg = e.registered ?? e
1057
+ if (reg.name)
1058
+ minted[reg.name] = {
1059
+ uuid: reg.payload_model_uuid ?? null,
1060
+ version: reg.version ?? null,
1061
+ unchanged: e.unchanged === true
1062
+ }
1063
+ }
1064
+ if (parsedBody && typeof parsedBody === 'object') {
1065
+ if (Array.isArray(parsedBody.data_schemas))
1066
+ parsedBody.data_schemas.forEach(addMint)
1067
+ if (parsedBody.foundation_schema) addMint(parsedBody.foundation_schema)
1068
+ }
1069
+ const names = [
1070
+ ...doc.entities
1071
+ .filter((e) => e.model === '@uniweb/data-schema')
1072
+ .map((e) => e.name),
1073
+ // The foundation-schema entity carries its `@scope/name` under `info`, not a
1074
+ // top-level `name` (that's the foundation-schema shape).
1075
+ ...doc.entities
1076
+ .filter((e) => e.model === '@uniweb/foundation-schema')
1077
+ .map((e) => e.info?.name ?? e.name)
1078
+ ].filter(Boolean)
1079
+ const entities = names.map((name) => ({
1080
+ name,
1081
+ ...(minted[name] || { uuid: null, version: null, unchanged: false })
1082
+ }))
1083
+ emitJson({
1084
+ ok: true,
1085
+ scope: scope || null,
1086
+ origin: client.origin,
1087
+ digest: digest || null,
1088
+ entities
1089
+ })
1090
+ }
1091
+ return { exitCode: 0 }
1092
+ }