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,692 @@
1
+ /**
2
+ * Bring-the-foundation-along — the freshness loop `uniweb publish` AND `uniweb
3
+ * push` run before handing a site to a backend (shipping-model.md §4).
4
+ *
5
+ * A publish must never ship a site pointing at stale or missing foundation code
6
+ * (the footgun: a site goes live referencing a version the catalog doesn't
7
+ * have). So when the site references a LOCAL foundation, the verb fingerprints it
8
+ * and reconciles with the catalog (§4):
9
+ *
10
+ * | version not yet registered | release it |
11
+ * | registered, code unchanged | skip the release (digest match) |
12
+ * | registered, code CHANGED | release it under the next version |
13
+ * | a NEWER version is registered | stop — `--bump` releases above it |
14
+ *
15
+ * ⭐ **Changed code is released by default** *[Diego, 2026-09-24]*. A site whose project
16
+ * holds its own foundation is a content+code bundle — *"a developer is used to code
17
+ * going with the site, so they don't think of bumping the code of a site"* — and the
18
+ * version there decides nothing: the only site reading it is the one being pushed.
19
+ * Since a registered version is immutable, the change goes under the next version
20
+ * above the registered one, written into the package's `package.json` first. ⛔ This
21
+ * reverses the version-bump release gate of 2026-06-23, whose reasoning — a deliberate,
22
+ * npm-style release — fits a foundation other sites use as a product; those sites pin
23
+ * a catalog ref and never reach this code. Its author's demo site, which names it by
24
+ * path, does — and gets the same default, by ruling [Diego, 2026-09-24]. The release is
25
+ * always the next patch, so a change that is not backwards compatible needs a higher
26
+ * version set in `package.json` by hand.
27
+ *
28
+ * ⛔ **A NEWER registered version still stops it.** It was released from another copy
29
+ * of the project, whose code this one may not have, and releasing above it would make
30
+ * the older code the newest — the code twin of push's content staleness gate. `--bump`
31
+ * releases above it anyway; `--no-release` ships the content bound to it.
32
+ *
33
+ * The freshness signal is the backend-stored, framework-computed digest (§4.1):
34
+ * no local state, multi-machine-safe. When the site references a published
35
+ * registry ref or a URL there's nothing to bring along. When the backend
36
+ * doesn't expose the stored digest yet, the compare DEGRADES to "ask" (same
37
+ * posture as `status --remote` on a 404).
38
+ *
39
+ * "Release" here is literally `uniweb register` run in the foundation directory
40
+ * — same build-if-stale → schema submit → code upload → digest the standalone
41
+ * verb does, so there is exactly one foundation-release path.
42
+ *
43
+ * ⭐ **`push` runs it too, and for a reason publish's framing does not cover.**
44
+ * [Diego, 2026-08-19] — *"A published site can only reference a registered
45
+ * foundation … In fact, not even a push can, because we can't preview the site in
46
+ * the frontend in that case."* A push is the collaboration verb: a teammate opens
47
+ * the site in the visual app straight after, and the app can only render it against
48
+ * foundation code the backend can serve. So an unregistered ref is not merely a
49
+ * publish-time problem — it is a broken preview, which is where a teammate actually
50
+ * meets it. `verb` names the caller in the messages so the fix the user is told to
51
+ * run is the command they ran.
52
+ */
53
+
54
+ import { readFileSync } from 'node:fs'
55
+ import { join } from 'node:path'
56
+ import { execFileSync } from 'node:child_process'
57
+
58
+ import {
59
+ detectFoundationType,
60
+ isExtensionUrl,
61
+ readFoundationName,
62
+ checkFoundationName
63
+ } from '@uniweb/build'
64
+ import { computeFoundationDigest } from '../utils/code-upload.js'
65
+ import { isNonInteractive } from '../utils/interactive.js'
66
+ import { readOrgFlag } from '../utils/args.js'
67
+ import { belongsToScope } from '../utils/registry-orgs.js'
68
+ import { writeJsonPreservingStyle } from '../utils/json-file.js'
69
+ import {
70
+ compareSemverPrecedence,
71
+ nextVersionAbove
72
+ } from '../utils/semver-precedence.js'
73
+
74
+ /**
75
+ * Resolve the site's LOCAL foundation — the one publish should bring along — or
76
+ * null when the site references a published registry ref / URL (the catalog
77
+ * already has it; nothing to do). Uses the SAME resolver the build uses
78
+ * (`detectFoundationType`), so "which foundation" never drifts between them.
79
+ *
80
+ * Its catalog name is `foundationScopedName(dir)` — a separate, async read, because
81
+ * the name lives in `main.js`, which is loaded rather than parsed.
82
+ *
83
+ * @param {string} siteDir
84
+ * @param {object} siteYml - parsed site.yml
85
+ * @returns {{ dir: string, version: string|null }|null}
86
+ */
87
+ export function resolveLocalFoundation(siteDir, siteYml) {
88
+ const decl = siteYml?.foundation
89
+ if (!decl) return null
90
+ let info
91
+ try {
92
+ info = detectFoundationType(decl, siteDir)
93
+ } catch {
94
+ // Unresolved declaration — the site build will surface the canonical
95
+ // error; bring-along simply has nothing local to act on.
96
+ return null
97
+ }
98
+ if (!info || info.type !== 'local' || !info.path) return null
99
+ return { dir: info.path, version: readPkgField(info.path, 'version') }
100
+ }
101
+
102
+ /**
103
+ * The site's LOCAL extensions — the ones publish must bring along. An extension IS
104
+ * a foundation (same build, same output), so it is declared and resolved the same
105
+ * way and goes through the SAME resolver, for the same reason `resolveLocalFoundation`
106
+ * does: "which code" must never drift between the build and the publish.
107
+ *
108
+ * A declaration that resolves to a URL or a catalog ref yields nothing local — the
109
+ * host already serves that code. Only workspace-local extensions need releasing.
110
+ *
111
+ * @param {string} siteDir
112
+ * @param {object} siteYml - parsed site.yml
113
+ * @returns {Array<{ decl: string, dir: string, version: string|null }>}
114
+ * `decl` is the authored declaration, which is the wire entry's `$id` — the key
115
+ * publish stamps the pinned ref back onto.
116
+ */
117
+ export function resolveLocalExtensions(siteDir, siteYml) {
118
+ const list = siteYml?.extensions
119
+ if (!Array.isArray(list)) return []
120
+ const out = []
121
+ for (const entry of list) {
122
+ // Only the name/ref form can be local; an explicit `url` never is.
123
+ const decl =
124
+ entry && typeof entry === 'object'
125
+ ? entry.ref || entry.name || null
126
+ : typeof entry === 'string'
127
+ ? entry
128
+ : null
129
+ if (!decl || isExtensionUrl(decl)) continue
130
+ let info
131
+ try {
132
+ info = detectFoundationType(decl, siteDir)
133
+ } catch {
134
+ // Unresolved — the site build surfaces the canonical error; nothing local.
135
+ continue
136
+ }
137
+ if (!info || info.type !== 'local' || !info.path) continue
138
+ out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
139
+ }
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * The foundation's scoped catalog name (`@org/name`): its name — `main.js`'s
145
+ * `name`, else package.json's, the one rule the build reads for the schema
146
+ * `register` submits (`readFoundationName`). ⭐ The scope is part of that name
147
+ * (2026-09-22), so a scoped name IS the catalog name. Null otherwise; the caller
148
+ * then treats the foundation as unreleased, and `register` — which it runs to
149
+ * release — is where a missing scope is chosen and written into the name, and a
150
+ * missing name is asked for.
151
+ *
152
+ * ⛔ A NAME THAT CANNOT REGISTER IS NULL, not looked up. `src` and `foundation`
153
+ * name a folder (`checkFoundationName`); looking one up would find some other
154
+ * project's `@org/src` — the very collision refusing them prevents.
155
+ *
156
+ * ⛔ A BARE NAME IS NULL too: it has not registered, since `register` writes the scope
157
+ * it registers under into the name. Until 2026-09-22 this joined a bare name with
158
+ * `package.json::uniweb.scope`; that key is refused now (`readFoundationName` throws,
159
+ * so a leftover reads as unreleased here and `register` says what to write instead).
160
+ * *(This read package.json alone until 2026-09-21 — `uniweb.id`, else `name` — and
161
+ * so named a foundation differently from the build whenever `main.js` named it.)*
162
+ *
163
+ * @param {string} dir - the foundation package
164
+ * @returns {Promise<string|null>}
165
+ */
166
+ export async function foundationScopedName(dir) {
167
+ let name
168
+ try {
169
+ ;({ name } = await readFoundationName(dir))
170
+ } catch {
171
+ return null
172
+ }
173
+ if (checkFoundationName(name)) return null
174
+ return name.startsWith('@') ? name : null
175
+ }
176
+
177
+ function readPkgField(dir, field) {
178
+ try {
179
+ return (
180
+ JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.[field] ||
181
+ null
182
+ )
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+
188
+ // Set the package's version, changing nothing else in the file — its key order,
189
+ // indentation and trailing newline stay as they were, so the diff is one line.
190
+ function writePkgVersion(dir, version) {
191
+ const path = join(dir, 'package.json')
192
+ const src = readFileSync(path, 'utf8')
193
+ writeJsonPreservingStyle(path, { ...JSON.parse(src), version }, src)
194
+ }
195
+
196
+ // What travels to the spawned `uniweb register` on the command line: --non-interactive,
197
+ // and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
198
+ // the child's environment (releaseFoundation), and the SESSION is the shared session
199
+ // file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
200
+ // backend flag (`--server` is the login's) and no `--token`.
201
+ //
202
+ // ⭐ The workspace travels because a bare name registers under the workspace the command
203
+ // works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
204
+ // alone: the child cannot read it from the session. One chosen at the login, or in
205
+ // UNIWEB_WORKSPACE, reaches the child on its own.
206
+ export function forwardedFlags(args) {
207
+ const out = []
208
+ if (isNonInteractive(args)) out.push('--non-interactive')
209
+ const org = readOrgFlag(args)
210
+ if (org) out.push('--org', org)
211
+ else if (args.includes('--personal')) out.push('--personal')
212
+ return out
213
+ }
214
+
215
+ /**
216
+ * Decide + (maybe) act on the site's local foundation before go-live.
217
+ *
218
+ * @param {object} o
219
+ * @param {import('./client.js').BackendClient} o.client
220
+ * @param {string} o.siteDir
221
+ * @param {object} o.siteYml
222
+ * @param {string[]} o.args
223
+ * @param {object} o.say - { ok, info, warn, err, dim } reporters
224
+ * @param {(q: string, def?: boolean) => Promise<boolean>} o.confirm
225
+ * @param {string} o.cliBin - the CLI entry (process.argv[1]) to re-spawn
226
+ * @param {boolean} [o.dryRun]
227
+ * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
228
+ * proceed:false → the caller should abort the publish (user declined). `ref`
229
+ * is the pinned `@scope/name@version` to stamp on the pushed site — read AFTER
230
+ * any release, so it reflects the released version + the scope register
231
+ * derived. Delivery is version-pinned end-to-end (the gateway serves a
232
+ * foundation only by a concrete version, no latest-resolution at serve time —
233
+ * collab framework↔backend), so an unversioned local ref MUST be pinned
234
+ * on the wire or the live site points at code the gateway can't serve. null
235
+ * when the site already references a registry ref / URL (no override needed)
236
+ * or no scoped ref can be formed.
237
+ */
238
+ export async function bringFoundationAlong({
239
+ client,
240
+ siteDir,
241
+ siteYml,
242
+ args,
243
+ say,
244
+ confirm,
245
+ cliBin,
246
+ dryRun = false,
247
+ verb = 'publish'
248
+ }) {
249
+ const local = resolveLocalFoundation(siteDir, siteYml)
250
+ if (!local) {
251
+ // Published registry ref / URL — the catalog (or the URL host) already
252
+ // serves the code, and site.yml already pins the version. Nothing to bring
253
+ // along, and no ref override (forward the site.yml ref verbatim).
254
+ return { released: false, proceed: true, ref: null }
255
+ }
256
+ return bringLocalCodeAlong({
257
+ client,
258
+ local,
259
+ kind: 'foundation',
260
+ args,
261
+ say,
262
+ confirm,
263
+ cliBin,
264
+ dryRun,
265
+ verb
266
+ })
267
+ }
268
+
269
+ /**
270
+ * Bring ONE piece of local code along — the primary foundation or one extension.
271
+ * Identical logic for both because an extension is a foundation; `kind` only names
272
+ * it in the messages.
273
+ *
274
+ * @param {object} o
275
+ * @param {{dir: string, version: string|null}} o.local
276
+ * @param {'foundation'|'extension'} o.kind
277
+ * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null, bumped?: string }>}
278
+ * `bumped` is the version written into package.json when changed code was released
279
+ * under the next version.
280
+ */
281
+ async function bringLocalCodeAlong({
282
+ client,
283
+ local,
284
+ kind,
285
+ args,
286
+ say,
287
+ confirm,
288
+ cliBin,
289
+ dryRun = false,
290
+ verb = 'publish'
291
+ }) {
292
+ const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
293
+ const scopedName = await foundationScopedName(local.dir)
294
+ // A name with no scope yet (it has never registered) is still a name: say it. ⛔ Until
295
+ // 2026-10-06 the label fell back to the KIND, and the line read "Releasing the foundation
296
+ // foundation@0.1.0".
297
+ const bareName = scopedName
298
+ ? null
299
+ : await readFoundationName(local.dir).then((r) => (r?.name && !checkFoundationName(r.name) ? r.name : null), () => null)
300
+ const shown = scopedName || bareName
301
+ const label =
302
+ shown || local.version
303
+ ? `${shown || kind}${local.version ? `@${local.version}` : ''}`
304
+ : `the local ${kind}`
305
+ const skipPrompts =
306
+ args.includes('--yes') ||
307
+ args.includes('--force') ||
308
+ args.includes('--no-verify')
309
+
310
+ // `--no-release`: ship the site against the code that is ALREADY released, and do
311
+ // not release the local changes.
312
+ //
313
+ // The intent is ordinary and had no name until 2026-08-19 — a developer edits
314
+ // content and a component in one sitting, and wants the copy fix live without
315
+ // shipping a half-finished component. Until now the way to get it was `--yes`,
316
+ // which means "do not ask me": the behaviour was reachable only as a side effect of
317
+ // a confirmation-skipper, which is discovery by accident.
318
+ //
319
+ // ⛔ It skips the RELEASE, never the REGISTRATION REQUIREMENT. A site referencing a
320
+ // foundation no deployment can resolve cannot be opened in the app at all, so where
321
+ // this flag cannot be honoured it REFUSES rather than doing the opposite of what it
322
+ // says (see the `!reg` branch).
323
+ const noRelease = args.includes('--no-release')
324
+
325
+ // `--bump`: when the registry holds a version NEWER than the local one, release the
326
+ // local code above it instead of stopping. That is all it does — changed code under
327
+ // the registered version is released without it, and unchanged code never is — so a
328
+ // script can pass it on every run.
329
+ const bump = args.includes('--bump')
330
+
331
+ if (bump && noRelease) {
332
+ say.err(
333
+ '`--bump` releases your local changes and `--no-release` ships without them — pass one of the two.'
334
+ )
335
+ return { released: false, proceed: false, refused: true, ref: null }
336
+ }
337
+
338
+ // The pinned ref to stamp on the pushed site — read at RETURN time (after any
339
+ // release), so it reflects the released version, the scope register derived and
340
+ // the name it asked for. null when no scoped ref can be formed (then the site.yml
341
+ // ref is forwarded).
342
+ const pinnedRef = async () => {
343
+ const s = await foundationScopedName(local.dir)
344
+ const v = readPkgField(local.dir, 'version')
345
+ return s && v ? `${s}@${v}` : null
346
+ }
347
+
348
+ // The ref for a run that releases NOTHING — the version the catalog actually holds,
349
+ // which is not always the local one. ⚠️ `pinnedRef()` reads the LOCAL package.json,
350
+ // so on a bumped-but-unreleased foundation it names a version nobody can serve. Any
351
+ // branch that skips a release must bind to this instead.
352
+ const registeredRef = (reg) =>
353
+ scopedName && reg?.latest_version
354
+ ? `${scopedName}@${reg.latest_version}`
355
+ : null
356
+
357
+ // Dry-run reports the intent WITHOUT touching the network — it must not force
358
+ // a login (the digest read is auth-gated). The real run does the compare.
359
+ if (dryRun) {
360
+ say.dim(
361
+ `${Kind} : ${label} — local; would release if changed or not yet registered`
362
+ )
363
+ // The ref still comes back where one can be formed: it is read from the
364
+ // foundation's own files, so it costs no network, and an offline preview that
365
+ // omitted it would emit a document the real run would not — the one thing `-o`
366
+ // exists to avoid.
367
+ //
368
+ // ⚠️ It is null for a foundation that has NEVER been registered and carries no
369
+ // scope (a fresh scaffold), because the scope is what `register` writes into its
370
+ // name (`settleFoundationScope`) — and for one with no name of its own (`src`),
371
+ // which `register` asks for. So the preview shows the authored value there, and the
372
+ // first real push — which releases, and so acquires both — sends the pinned ref
373
+ // instead. That gap is unavoidable offline: before the first release there is
374
+ // no registered name to name.
375
+ return { released: false, proceed: true, ref: await pinnedRef() }
376
+ }
377
+
378
+ // Ask the catalog what it has. Null → not registered (or the backend can't
379
+ // answer / no scoped name to look up) → release.
380
+ const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
381
+
382
+ // Every release below goes through here, so a refusal for a scope you are not a member
383
+ // of is said as that, with the ways on (`explainReleaseFailure`).
384
+ const release = async () => {
385
+ try {
386
+ return releaseFoundation(local, args, cliBin, say, client?.origin)
387
+ } catch (err) {
388
+ throw await explainReleaseFailure(err, { client, local, reg, verb })
389
+ }
390
+ }
391
+
392
+ if (!reg) {
393
+ // ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
394
+ // and shipping anyway would leave a site the app cannot open — so stop and say so.
395
+ if (noRelease) {
396
+ say.err(
397
+ `--no-release, but ${label} has never been released — there is no registered version to bind to.`
398
+ )
399
+ say.dim(
400
+ `A site referencing an unreleased ${kind} cannot be opened in the app, so this cannot be skipped.`
401
+ )
402
+ say.dim(`Drop \`--no-release\` to release it now.`)
403
+ return { released: false, proceed: false, refused: true, ref: null }
404
+ }
405
+ say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
406
+ return {
407
+ released: await release(),
408
+ proceed: true,
409
+ ref: await pinnedRef()
410
+ }
411
+ }
412
+
413
+ // The next version above the registered one — what changed code is released under
414
+ // when the local version is taken. Null when the registered version is not SemVer,
415
+ // and then there is nothing to count up from.
416
+ const bumpTo = nextVersionAbove(reg.latest_version)
417
+
418
+ // Write it into package.json, then release. `register` rebuilds on its own when the
419
+ // built schema's version no longer matches package.json, so the release carries it.
420
+ // `optOut` names `--no-release` for a release nobody asked for by flag.
421
+ //
422
+ // ⚖️ A failed release leaves the new version in the file, and that is the useful
423
+ // state: the next run finds a local version above the registered one and releases
424
+ // it, with nothing to redo.
425
+ const releaseAsNext = async (why, { optOut = false } = {}) => {
426
+ if (!bumpTo) {
427
+ say.err(
428
+ `Cannot release ${label} under a new version: the registered ${reg.latest_version} is not a SemVer version.`
429
+ )
430
+ say.dim(
431
+ `Nothing was sent. Set a version above it in the ${kind}'s package.json, then re-run \`uniweb ${verb}\`; or \`uniweb ${verb} --no-release\` sends content bound to the released ${reg.latest_version}.`
432
+ )
433
+ return { released: false, proceed: false, refused: true, ref: null }
434
+ }
435
+ writePkgVersion(local.dir, bumpTo)
436
+ say.info(`Releasing the ${kind} ${scopedName || kind} as ${bumpTo} — ${why}…`)
437
+ const released = await release()
438
+ say.dim(`The ${kind}'s package.json now says ${bumpTo} — commit it.`)
439
+ if (optOut) say.dim('To send content without releasing code, pass `--no-release`.')
440
+ return { released, proceed: true, bumped: bumpTo, ref: await pinnedRef() }
441
+ }
442
+
443
+ // Registered — fingerprint the local build and compare. Build first so the
444
+ // digest reflects current source (idempotent: a no-op when already fresh).
445
+ buildFoundation(local, cliBin)
446
+ const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
447
+
448
+ if (reg.digest && localDigest && reg.digest === localDigest) {
449
+ say.dim(
450
+ `${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
451
+ )
452
+ return { released: false, proceed: true, ref: await pinnedRef() }
453
+ }
454
+
455
+ // A different version locally.
456
+ if (local.version && local.version !== reg.latest_version) {
457
+ const order = compareSemverPrecedence(local.version, reg.latest_version)
458
+ // Equal in precedence (build metadata apart) is the registered version itself, and
459
+ // is treated as it, below.
460
+ if (order !== 0) {
461
+ if (noRelease) {
462
+ // ⚠️ Bind to the REGISTERED version, not the local one. `pinnedRef()` would
463
+ // return the bumped-but-unreleased `local.version` here — a ref no deployment
464
+ // can serve, which is the very failure this flag must not create.
465
+ say.info(
466
+ `Keeping the released ${kind} ${reg.latest_version} — local ${local.version} not released (\`--no-release\`).`
467
+ )
468
+ return { released: false, proceed: true, ref: registeredRef(reg) }
469
+ }
470
+
471
+ // ⛔ OLDER than the registered latest: that version was released from another
472
+ // copy of the project, and this one may not have its code. Releasing above it
473
+ // would make this code the newest and bind the site to it — a teammate's release
474
+ // undone by a push that never saw it. So it stops, and names the ways on.
475
+ //
476
+ // Until 2026-09-24 it was submitted, the registry deciding: it took a version it
477
+ // already held with this very code (a resume), and refused anything else. Only
478
+ // the latest version's digest is readable, and the digest folds in the version,
479
+ // so "this code, under an older version" cannot be told apart from here.
480
+ if (order === -1) {
481
+ if (bump) {
482
+ say.warn(
483
+ `The registry holds ${reg.latest_version}, newer than your ${label} — releasing your code above it (\`--bump\`). If ${reg.latest_version} is someone else's release, its changes are not in yours.`
484
+ )
485
+ return releaseAsNext(`above the registered ${reg.latest_version}`)
486
+ }
487
+ say.err(
488
+ `The registry holds ${kind} ${scopedName || kind} ${reg.latest_version}, newer than your ${local.version} — a release this copy does not have.`
489
+ )
490
+ say.dim(
491
+ `Nothing was sent. Pull the change that released it, then re-run \`uniweb ${verb}\`; or:`
492
+ )
493
+ if (bumpTo) {
494
+ say.dim(` • \`uniweb ${verb} --bump\` — releases your code as ${bumpTo}, above it`)
495
+ }
496
+ say.dim(
497
+ ` • \`uniweb ${verb} --no-release\` — sends content bound to the released ${reg.latest_version}`
498
+ )
499
+ return { released: false, proceed: false, refused: true, ref: null }
500
+ }
501
+
502
+ // Newer than anything registered — a new version, released as it stands. Not
503
+ // SemVer (`order === null`) — nothing to compare, so the same, and the registry
504
+ // decides; `register` prints its answer if it is a no.
505
+ say.info(
506
+ order === 1
507
+ ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
508
+ : `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
509
+ )
510
+ return {
511
+ released: await release(),
512
+ proceed: true,
513
+ ref: await pinnedRef()
514
+ }
515
+ }
516
+ }
517
+
518
+ // The registered version — but the digest differs, or the backend can't confirm it.
519
+ if (!reg.digest) {
520
+ // Degrade: the backend doesn't return the stored digest yet, so we can't
521
+ // be sure the registered version matches local. Offer to re-deliver.
522
+ if (noRelease) {
523
+ say.info(
524
+ `Keeping the released ${kind} ${reg.latest_version} — nothing released (\`--no-release\`).`
525
+ )
526
+ return { released: false, proceed: true, ref: registeredRef(reg) }
527
+ }
528
+ say.warn(
529
+ `Can't verify the registered ${label} matches your local copy (backend returned no digest).`
530
+ )
531
+ if (skipPrompts || isNonInteractive(args)) {
532
+ // ⚖️ Not released: nothing says the code changed, and releasing on no evidence
533
+ // would mint a version on every push against such a backend.
534
+ say.dim(
535
+ `Proceeding without re-releasing — to release a change, set a higher version in the ${kind}'s package.json.`
536
+ )
537
+ return { released: false, proceed: true, ref: await pinnedRef() }
538
+ }
539
+ const reRelease = await confirm(
540
+ `Re-release ${label} to be sure its code is current?`,
541
+ false
542
+ )
543
+ if (reRelease)
544
+ return {
545
+ released: await release(),
546
+ proceed: true,
547
+ ref: await pinnedRef()
548
+ }
549
+ return { released: false, proceed: true, ref: await pinnedRef() }
550
+ }
551
+
552
+ // The code changed and the version did not. A registered version is immutable, so
553
+ // the change is released under the next one.
554
+ if (noRelease) {
555
+ say.info(
556
+ `Keeping the released ${kind} ${reg.latest_version} — your local changes are not released (\`--no-release\`).`
557
+ )
558
+ return { released: false, proceed: true, ref: registeredRef(reg) }
559
+ }
560
+
561
+ // ⭐ RELEASED BY DEFAULT — for a person and an agent alike, with no question.
562
+ //
563
+ // Until 2026-09-24 this stopped: a terminal was asked, a run without one refused, and
564
+ // `--yes` shipped the REGISTERED code. That guarded against a site bound to code that
565
+ // was not the working tree's — which is exactly what releasing prevents: the site is
566
+ // bound to the code just released, as it is to the content pushed with it. `--yes`
567
+ // has nothing left to consent to here, and a question whose answer is nearly always
568
+ // yes teaches people to stop reading it.
569
+ return releaseAsNext(`its code changed since ${reg.latest_version} was registered`, {
570
+ optOut: true
571
+ })
572
+ }
573
+
574
+ // Build the foundation so its dist/ can be fingerprinted. Idempotent — the
575
+ // foundation build no-ops when already fresh.
576
+ function buildFoundation(local, cliBin) {
577
+ execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
578
+ cwd: local.dir,
579
+ stdio: 'inherit',
580
+ // A step of this command, so the build skips the next-step hints meant for
581
+ // someone who ran `uniweb build` themselves.
582
+ env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
583
+ })
584
+ }
585
+
586
+ // Release = `uniweb register` in the foundation directory (the one foundation
587
+ // release path). Returns true on success; throws to the caller on failure so
588
+ // publish stops before going live with missing code.
589
+ function releaseFoundation(local, args, cliBin, say, origin) {
590
+ console.log('')
591
+ // ⭐ Pinned to the parent's backend through the environment, so the release lands
592
+ // where the publish goes by construction — not because two processes happen to read
593
+ // the same session file the same way.
594
+ execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
595
+ cwd: local.dir,
596
+ stdio: 'inherit',
597
+ env: origin ? { ...process.env, UNIWEB_SERVER: origin } : process.env
598
+ })
599
+ console.log('')
600
+ return true
601
+ }
602
+
603
+ /**
604
+ * A failed release, explained when the cause is a scope you are not a member of
605
+ * *[Diego, 2026-10-06]* — or the error as it came.
606
+ *
607
+ * The scope is the one in the foundation's name: `register` writes a bare name's scope
608
+ * before it submits, so a refused release leaves the name scoped. When the orgs read
609
+ * says the account is neither that scope's owner nor a member of its org, the error
610
+ * says so (`notMember`) and carries the ways on (`ways`): `--no-release`, where a
611
+ * released version exists to send the content against, and a member releasing it.
612
+ * Its message is what the refusal means for the push — nothing was sent — since the
613
+ * `register` it ran has just printed the refusal itself, membership and the registry's
614
+ * own sentence included (measured against a local backend, 2026-10-06: HTTP 400, no
615
+ * typed reason).
616
+ *
617
+ * ⛔ The registry decides membership; this only names it after a refusal, and passes
618
+ * the error through whenever it cannot tell.
619
+ *
620
+ * @param {Error} err - the release's failure
621
+ * @param {object} o
622
+ * @param {import('./client.js').BackendClient} o.client
623
+ * @param {{ dir: string }} o.local
624
+ * @param {{ latest_version?: string }|null} o.reg - the catalog's answer for this name
625
+ * @param {string} o.verb - `push` or `publish`, for the ways on
626
+ * @returns {Promise<Error>}
627
+ */
628
+ export async function explainReleaseFailure(err, { client, local, reg, verb }) {
629
+ const name = await foundationScopedName(local.dir).catch(() => null)
630
+ const scope = name ? name.split('/')[0] : null
631
+ if (!scope || typeof client?.fetchOrgs !== 'function') return err
632
+ const member = belongsToScope(scope, await client.fetchOrgs().catch(() => null))
633
+ if (member !== false) return err
634
+ const explained = new Error(`${name} was not released, so nothing was sent.`)
635
+ explained.notMember = true
636
+ explained.ways = reg?.latest_version
637
+ ? [
638
+ `Send the content against the released ${reg.latest_version}: \`uniweb ${verb} --no-release\`.`,
639
+ `Or ask a member of ${scope} to release it.`
640
+ ]
641
+ : [`Ask a member of ${scope} to release it.`]
642
+ return explained
643
+ }
644
+
645
+ /**
646
+ * Bring the site's LOCAL extensions along — the exact parallel of
647
+ * `bringFoundationAlong`, run for each workspace-local extension.
648
+ *
649
+ * An extension is a foundation, so it gets a foundation's freshness guarantee:
650
+ * released when unregistered or newly versioned, skipped on a digest match, and
651
+ * never silently shipped stale. Before this, a site could go live against a stale
652
+ * extension with nothing noticing — the primary was covered and the rest were not.
653
+ *
654
+ * @param {object} o - same shape as bringFoundationAlong
655
+ * @returns {Promise<{ proceed: boolean, released: number, pins: Object<string,string> }>}
656
+ * `pins` maps each authored declaration (the wire entry's `$id`) → the pinned
657
+ * `@scope/name@version`, for `emitSyncPackages({ injectExtensions })`. Delivery is
658
+ * version-pinned, so an unpinned local name on the wire points at code the host
659
+ * cannot serve — the same reason the primary's ref is stamped.
660
+ */
661
+ export async function bringExtensionsAlong({
662
+ client,
663
+ siteDir,
664
+ siteYml,
665
+ args,
666
+ say,
667
+ confirm,
668
+ cliBin,
669
+ dryRun = false
670
+ }) {
671
+ const locals = resolveLocalExtensions(siteDir, siteYml)
672
+ const pins = {}
673
+ let released = 0
674
+ for (const local of locals) {
675
+ const r = await bringLocalCodeAlong({
676
+ client,
677
+ local,
678
+ kind: 'extension',
679
+ args,
680
+ say,
681
+ confirm,
682
+ cliBin,
683
+ dryRun
684
+ })
685
+ // A declined prompt aborts the whole publish, exactly as it does for the
686
+ // primary — a site live against half its code is worse than not shipping.
687
+ if (!r.proceed) return { proceed: false, released, pins: {} }
688
+ if (r.released) released += 1
689
+ if (r.ref) pins[local.decl] = r.ref
690
+ }
691
+ return { proceed: true, released, pins }
692
+ }