uniweb 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,14 @@ import { readFileSync } from 'node:fs'
36
36
  import { join } from 'node:path'
37
37
  import { execFileSync } from 'node:child_process'
38
38
 
39
- import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
39
+ import {
40
+ detectFoundationType,
41
+ isExtensionUrl,
42
+ readFoundationName,
43
+ checkFoundationName
44
+ } from '@uniweb/build'
40
45
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
46
  import { isNonInteractive } from '../utils/interactive.js'
42
- import { publishScope } from '../utils/registry-orgs.js'
43
47
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
44
48
 
45
49
  /**
@@ -48,9 +52,12 @@ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
48
52
  * already has it; nothing to do). Uses the SAME resolver the build uses
49
53
  * (`detectFoundationType`), so "which foundation" never drifts between them.
50
54
  *
55
+ * Its catalog name is `foundationScopedName(dir)` — a separate, async read, because
56
+ * the name lives in `main.js`, which is loaded rather than parsed.
57
+ *
51
58
  * @param {string} siteDir
52
59
  * @param {object} siteYml - parsed site.yml
53
- * @returns {{ dir: string, scopedName: string|null, version: string|null }|null}
60
+ * @returns {{ dir: string, version: string|null }|null}
54
61
  */
55
62
  export function resolveLocalFoundation(siteDir, siteYml) {
56
63
  const decl = siteYml?.foundation
@@ -64,11 +71,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
64
71
  return null
65
72
  }
66
73
  if (!info || info.type !== 'local' || !info.path) return null
67
- return {
68
- dir: info.path,
69
- scopedName: foundationScopedName(info.path),
70
- version: readPkgField(info.path, 'version')
71
- }
74
+ return { dir: info.path, version: readPkgField(info.path, 'version') }
72
75
  }
73
76
 
74
77
  /**
@@ -82,7 +85,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
82
85
  *
83
86
  * @param {string} siteDir
84
87
  * @param {object} siteYml - parsed site.yml
85
- * @returns {Array<{ decl: string, dir: string, scopedName: string|null, version: string|null }>}
88
+ * @returns {Array<{ decl: string, dir: string, version: string|null }>}
86
89
  * `decl` is the authored declaration, which is the wire entry's `$id` — the key
87
90
  * publish stamps the pinned ref back onto.
88
91
  */
@@ -107,41 +110,43 @@ export function resolveLocalExtensions(siteDir, siteYml) {
107
110
  continue
108
111
  }
109
112
  if (!info || info.type !== 'local' || !info.path) continue
110
- out.push({
111
- decl,
112
- dir: info.path,
113
- scopedName: foundationScopedName(info.path),
114
- version: readPkgField(info.path, 'version')
115
- })
113
+ out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
116
114
  }
117
115
  return out
118
116
  }
119
117
 
120
- // The foundation's scoped catalog name (`@org/name`) from its package.json — an
121
- // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
122
- // yields a scoped name (then we can't look up the registered version, so the
123
- // caller treats the foundation as "release it and let register pick the scope").
124
- //
125
- // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
126
- // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
127
- // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
- // the catalog does not have, so every push re-released it, and pinned the site
129
- // to that same wrong name.
130
- //
131
- // ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
132
- // may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
133
- // bare one looked the foundation up as `acme/src` and pinned the site to that.
134
- function foundationScopedName(dir) {
118
+ /**
119
+ * The foundation's scoped catalog name (`@org/name`): its name — `main.js`'s
120
+ * `name`, else package.json's, the one rule the build reads for the schema
121
+ * `register` submits (`readFoundationName`). ⭐ The scope is part of that name
122
+ * (2026-09-22), so a scoped name IS the catalog name. Null otherwise; the caller
123
+ * then treats the foundation as unreleased, and `register` — which it runs to
124
+ * release — is where a missing scope is chosen and written into the name, and a
125
+ * missing name is asked for.
126
+ *
127
+ * ⛔ A NAME THAT CANNOT REGISTER IS NULL, not looked up. `src` and `foundation`
128
+ * name a folder (`checkFoundationName`); looking one up would find some other
129
+ * project's `@org/src` — the very collision refusing them prevents.
130
+ *
131
+ * ⛔ A BARE NAME IS NULL too: it has not registered, since `register` writes the scope
132
+ * it registers under into the name. Until 2026-09-22 this joined a bare name with
133
+ * `package.json::uniweb.scope`; that key is refused now (`readFoundationName` throws,
134
+ * so a leftover reads as unreleased here and `register` says what to write instead).
135
+ * *(This read package.json alone until 2026-09-21 — `uniweb.id`, else `name` — and
136
+ * so named a foundation differently from the build whenever `main.js` named it.)*
137
+ *
138
+ * @param {string} dir - the foundation package
139
+ * @returns {Promise<string|null>}
140
+ */
141
+ export async function foundationScopedName(dir) {
142
+ let name
135
143
  try {
136
- const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
137
- const name = pkg?.uniweb?.id || pkg?.name
138
- if (typeof name === 'string' && name.startsWith('@')) return name
139
- const scope = publishScope(pkg?.uniweb?.scope)
140
- if (scope && name) return `${scope}/${name}`
141
- return null
144
+ ;({ name } = await readFoundationName(dir))
142
145
  } catch {
143
146
  return null
144
147
  }
148
+ if (checkFoundationName(name)) return null
149
+ return name.startsWith('@') ? name : null
145
150
  }
146
151
 
147
152
  function readPkgField(dir, field) {
@@ -225,7 +230,7 @@ export async function bringFoundationAlong({
225
230
  * it in the messages.
226
231
  *
227
232
  * @param {object} o
228
- * @param {{dir: string, scopedName: string|null, version: string|null}} o.local
233
+ * @param {{dir: string, version: string|null}} o.local
229
234
  * @param {'foundation'|'extension'} o.kind
230
235
  * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
231
236
  */
@@ -241,9 +246,10 @@ async function bringLocalCodeAlong({
241
246
  verb = 'publish'
242
247
  }) {
243
248
  const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
249
+ const scopedName = await foundationScopedName(local.dir)
244
250
  const label =
245
- local.scopedName || local.version
246
- ? `${local.scopedName || kind}${local.version ? `@${local.version}` : ''}`
251
+ scopedName || local.version
252
+ ? `${scopedName || kind}${local.version ? `@${local.version}` : ''}`
247
253
  : `the local ${kind}`
248
254
  const skipPrompts =
249
255
  args.includes('--yes') ||
@@ -266,10 +272,11 @@ async function bringLocalCodeAlong({
266
272
  const noRelease = args.includes('--no-release')
267
273
 
268
274
  // The pinned ref to stamp on the pushed site — read at RETURN time (after any
269
- // release), so it reflects the released version + the scope register derived.
270
- // null when no scoped ref can be formed (then the site.yml ref is forwarded).
271
- const pinnedRef = () => {
272
- const s = foundationScopedName(local.dir)
275
+ // release), so it reflects the released version, the scope register derived and
276
+ // the name it asked for. null when no scoped ref can be formed (then the site.yml
277
+ // ref is forwarded).
278
+ const pinnedRef = async () => {
279
+ const s = await foundationScopedName(local.dir)
273
280
  const v = readPkgField(local.dir, 'version')
274
281
  return s && v ? `${s}@${v}` : null
275
282
  }
@@ -279,8 +286,8 @@ async function bringLocalCodeAlong({
279
286
  // so on a bumped-but-unreleased foundation it names a version nobody can serve. Any
280
287
  // branch that skips a release must bind to this instead.
281
288
  const registeredRef = (reg) =>
282
- local.scopedName && reg?.latest_version
283
- ? `${local.scopedName}@${reg.latest_version}`
289
+ scopedName && reg?.latest_version
290
+ ? `${scopedName}@${reg.latest_version}`
284
291
  : null
285
292
 
286
293
  // Dry-run reports the intent WITHOUT touching the network — it must not force
@@ -290,24 +297,23 @@ async function bringLocalCodeAlong({
290
297
  `${Kind} : ${label} — local; would release if changed or not yet registered`
291
298
  )
292
299
  // The ref still comes back where one can be formed: it is read from the
293
- // foundation's own package.json, so it costs no network, and an offline preview
294
- // that omitted it would emit a document the real run would not — the one thing
295
- // `-o` exists to avoid.
300
+ // foundation's own files, so it costs no network, and an offline preview that
301
+ // omitted it would emit a document the real run would not — the one thing `-o`
302
+ // exists to avoid.
296
303
  //
297
304
  // ⚠️ It is null for a foundation that has NEVER been registered and carries no
298
- // scope (a freshly scaffolded `name: "src"`), because the scope is what
299
- // `register` writes back (`writePkgScope`). So the preview shows the authored
300
- // value there, and the first real push — which releases, and so acquires the
301
- // scope — sends the pinned ref instead. That gap is unavoidable offline: before
302
- // the first release there is no registered name to name.
303
- return { released: false, proceed: true, ref: pinnedRef() }
305
+ // scope (a fresh scaffold), because the scope is what `register` writes into its
306
+ // name (`settleFoundationScope`) — and for one with no name of its own (`src`),
307
+ // which `register` asks for. So the preview shows the authored value there, and the
308
+ // first real push — which releases, and so acquires both — sends the pinned ref
309
+ // instead. That gap is unavoidable offline: before the first release there is
310
+ // no registered name to name.
311
+ return { released: false, proceed: true, ref: await pinnedRef() }
304
312
  }
305
313
 
306
314
  // Ask the catalog what it has. Null → not registered (or the backend can't
307
315
  // answer / no scoped name to look up) → release.
308
- const reg = local.scopedName
309
- ? await client.readFoundationLatest(local.scopedName)
310
- : null
316
+ const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
311
317
 
312
318
  if (!reg) {
313
319
  // ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
@@ -326,7 +332,7 @@ async function bringLocalCodeAlong({
326
332
  return {
327
333
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
328
334
  proceed: true,
329
- ref: pinnedRef()
335
+ ref: await pinnedRef()
330
336
  }
331
337
  }
332
338
 
@@ -339,7 +345,7 @@ async function bringLocalCodeAlong({
339
345
  say.dim(
340
346
  `${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
341
347
  )
342
- return { released: false, proceed: true, ref: pinnedRef() }
348
+ return { released: false, proceed: true, ref: await pinnedRef() }
343
349
  }
344
350
 
345
351
  // A different version locally → a new version to release.
@@ -369,7 +375,7 @@ async function bringLocalCodeAlong({
369
375
  return {
370
376
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
371
377
  proceed: true,
372
- ref: pinnedRef()
378
+ ref: await pinnedRef()
373
379
  }
374
380
  }
375
381
 
@@ -390,7 +396,7 @@ async function bringLocalCodeAlong({
390
396
  say.dim(
391
397
  'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to release a change.'
392
398
  )
393
- return { released: false, proceed: true, ref: pinnedRef() }
399
+ return { released: false, proceed: true, ref: await pinnedRef() }
394
400
  }
395
401
  const reRelease = await confirm(
396
402
  `Re-release ${label} to be sure its code is current?`,
@@ -400,9 +406,9 @@ async function bringLocalCodeAlong({
400
406
  return {
401
407
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
402
408
  proceed: true,
403
- ref: pinnedRef()
409
+ ref: await pinnedRef()
404
410
  }
405
- return { released: false, proceed: true, ref: pinnedRef() }
411
+ return { released: false, proceed: true, ref: await pinnedRef() }
406
412
  }
407
413
 
408
414
  // Case 3 (§4): the code was edited but the version wasn't bumped. The
@@ -438,7 +444,7 @@ async function bringLocalCodeAlong({
438
444
  `Local ${label} differs from the registered ${reg.latest_version} and the version wasn't bumped — ` +
439
445
  `shipping against the registered code. Your local changes will NOT be live.`
440
446
  )
441
- return { released: false, proceed: true, ref: pinnedRef() }
447
+ return { released: false, proceed: true, ref: await pinnedRef() }
442
448
  }
443
449
 
444
450
  // 2. Nobody to ask. Refuse, and name both real options as runnable commands so
@@ -476,7 +482,7 @@ async function bringLocalCodeAlong({
476
482
  )
477
483
  return { released: false, proceed: false, ref: null }
478
484
  }
479
- return { released: false, proceed: true, ref: pinnedRef() }
485
+ return { released: false, proceed: true, ref: await pinnedRef() }
480
486
  }
481
487
 
482
488
  // Build the foundation so its dist/ can be fingerprinted. Idempotent — the