uniweb 0.60.0 → 0.62.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.
@@ -33,7 +33,7 @@
33
33
  * FIRST publish of a site reads it — that create is
34
34
  * what decides which org owns the site and whose
35
35
  * storage its assets are charged to. It is then
36
- * recorded as `site.yml::$org` and replayed, so it
36
+ * recorded in `sync.json` and replayed, so it
37
37
  * never has to be re-typed.
38
38
  * uniweb publish --personal Own the new site personally, deliberately. Sends
39
39
  * NO `as_org` — byte-identical to the wire before
@@ -535,12 +535,18 @@ export async function publish(args = []) {
535
535
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
536
536
  const resolveModel = makeModelResolver({ client, offline: false })
537
537
 
538
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
539
- // destructive — a placeholder file, created meaning to fill it in — so the count
540
- // is reported and confirmed before anything is sent.
538
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
539
+ // is destructive — a directory emptied by accident, or kept with only a
540
+ // placeholder — so the count is reported and confirmed before anything is sent.
541
+ //
542
+ // ⛔ `backend` WAS MISSING HERE until 2026-09-21, and it is what the count is read
543
+ // under: placements are banked per backend, so with none the guard counted zero
544
+ // and never asked — `publish` sent an empty folder without a word while `push`
545
+ // stopped for the same one.
541
546
  {
542
547
  const guard = await guardEmptyRecords({
543
548
  siteDir,
549
+ backend: client.origin,
544
550
  args,
545
551
  warn: say.warn,
546
552
  note: say.dim
@@ -65,7 +65,7 @@ import {
65
65
  import { tmpdir } from 'node:os'
66
66
  import { createHash } from 'node:crypto'
67
67
  import { createInterface } from 'node:readline/promises'
68
- import { join, dirname, relative } from 'node:path'
68
+ import { join, dirname, relative, resolve } from 'node:path'
69
69
  import yaml from 'js-yaml'
70
70
  import { downloadMissingAssets } from '../backend/asset-download.js'
71
71
  import {
@@ -843,9 +843,9 @@ export async function pull(args = [], deps = {}) {
843
843
  }
844
844
  // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
845
845
  // `$model` names the pool folder — not by any query that happens to select
846
- // it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
847
- // model the producer resolved to `@org/x` is placed back where the author
848
- // wrote it.
846
+ // it. `recordsToProject` reads the site's org itself (this backend's, from
847
+ // `sync.json`), so a `@/x` model the producer resolved to `@org/x` is placed
848
+ // back where the author wrote it.
849
849
  const report = recordsToProject({
850
850
  folderDoc,
851
851
  recordDocs,
@@ -856,7 +856,16 @@ export async function pull(args = [], deps = {}) {
856
856
  resolveDeclaration: (name) => declByModel.get(name) || null
857
857
  }
858
858
  })
859
- if (report.records === 'updated') info('Wrote records.yml')
859
+ // The folder's organization, `folder.yml` in the records directory — named as
860
+ // the author would write it (`records/folder.yml`, or under `paths.records`).
861
+ if (report.records === 'updated') {
862
+ info(`Wrote ${report.recordsFile}`)
863
+ wrote.push(resolve(siteDir, report.recordsFile))
864
+ } else if (report.records === 'removed') {
865
+ // The backend's folder has no sub-folders, and the file holds nothing else.
866
+ info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
867
+ removed.push(resolve(siteDir, report.recordsFile))
868
+ }
860
869
  records += report.placed.length + report.updated.length
861
870
  for (const s of report.skipped)
862
871
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -890,7 +899,8 @@ export async function pull(args = [], deps = {}) {
890
899
  siteDir,
891
900
  [
892
901
  ...wrote,
893
- ...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
902
+ // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
903
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
894
904
  join(siteDir, f)
895
905
  )
896
906
  ],
@@ -14,7 +14,7 @@
14
14
  * the backend owns the site's `@uniweb/folder`, so the framework never holds a
15
15
  * folder uuid. Records still round-trip their own `$uuid`
16
16
  * (back-filled into their source files). site-content items carry a per-item `$uuid`
17
- * too, stamped at emit from the identity cache rather than from author files — without
17
+ * too, stamped at emit from `sync.json` rather than from author files — without
18
18
  * it the backend reads every record as new and recreates every page and section row.
19
19
  * Push-only, and gated on the backend's per-entity `version`
20
20
  * (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
@@ -30,8 +30,8 @@
30
30
  * uniweb push --org @org Own the new site under @org (alias: --as-org).
31
31
  * Read only on the FIRST push of a site — it
32
32
  * decides which org owns it, and whose storage
33
- * its assets are charged to. Recorded as
34
- * `site.yml::$org` and replayed after that.
33
+ * its assets are charged to. Recorded in
34
+ * `sync.json` and replayed after that.
35
35
  * Without it, you are asked once.
36
36
  * uniweb push --personal Own the new site personally, deliberately.
37
37
  * Sends NO `as_org` — the same wire as before
@@ -388,10 +388,10 @@ export async function push(args = [], deps = {}) {
388
388
  // It also has to be this emit that carries `assetRewrite` below: the push cache
389
389
  // stores hashes of the REWRITTEN content, so the emit compared against it must
390
390
  // rewrite too, or every entity reads as changed forever.
391
- // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
392
- // destructive — a placeholder file, created meaning to fill it in — so the count
393
- // is reported and confirmed before anything is sent. The format stays honest;
394
- // the asking happens here.
391
+ // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
392
+ // is destructive — a directory emptied by accident, or kept with only a
393
+ // placeholder — so the count is reported and confirmed before anything is sent.
394
+ // The format stays honest; the asking happens here.
395
395
  if (!dryRun) {
396
396
  const guard = await guardEmptyRecords({ siteDir, backend: client.origin, args, warn, note })
397
397
  if (!guard.ok) return { exitCode: 1 }
@@ -3,15 +3,14 @@
3
3
  * standalone schemas package (the data schemas alone, no foundation), to the
4
4
  * registry as one names-only `.uwx` document (uwx-format.md §5).
5
5
  *
6
- * `uniweb login && uniweb register`. Distinct from `uniweb publish` (which
7
- * targets the legacy platform) — `register` talks to the registry over HTTP at a
8
- * configurable endpoint.
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
9
  *
10
- * If the foundation's `dist/` is missing or version-stale (the baked schema
11
- * version differs from package.json), `register` builds it first — the same
12
- * build-if-stale `uniweb publish` does, so `register` is a full drop-in.
13
- * Preview paths (`--dry-run`, `-o`) never write to `dist/`; they require a
14
- * pre-built foundation.
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.
15
14
  *
16
15
  * Run from a foundation, or from a schemas-only package — a package that exports
17
16
  * schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
@@ -64,20 +63,27 @@ import {
64
63
  statSync
65
64
  } from 'node:fs'
66
65
  import { execSync } from 'node:child_process'
67
- import { resolve, join } from 'node:path'
66
+ import { resolve, join, relative } from 'node:path'
67
+ import prompts from 'prompts'
68
68
  import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
69
69
  import {
70
70
  classifyPackage,
71
71
  isSchemasPackage,
72
- collectStandaloneSchemas
72
+ collectStandaloneSchemas,
73
+ readFoundationName,
74
+ checkFoundationName
73
75
  } from '@uniweb/build'
76
+ import {
77
+ suggestFoundationName,
78
+ writeFoundationName
79
+ } from '../utils/foundation-name.js'
74
80
  import { readRegistryAuth } from '../utils/registry-auth.js'
75
81
  import {
76
82
  collectDistFiles,
77
83
  computeFoundationDigest,
78
84
  readRuntimePin
79
85
  } from '../utils/code-upload.js'
80
- import { deriveScope } from '../utils/registry-orgs.js'
86
+ import { deriveScope, publishScope } from '../utils/registry-orgs.js'
81
87
  import { BackendClient } from '../backend/client.js'
82
88
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
83
89
  import {
@@ -209,6 +215,84 @@ async function resolveFoundationDir(args) {
209
215
  process.exit(1)
210
216
  }
211
217
 
218
+ /**
219
+ * The foundation's name, settled before anything is built or sent.
220
+ *
221
+ * A foundation registers as `@org/<name>`, and sites pin it by that name. The name
222
+ * is `main.js`'s `name`, else package.json's — `readFoundationName`, the rule the
223
+ * build reads for the schema this command submits. ⛔ `src` and `foundation` are
224
+ * the folder the code sits in, not the code: a project scaffolded before the name
225
+ * moved to main.js has package name `src`, and every such project in an org would
226
+ * register the same `@org/src`. So a name that cannot register is ASKED FOR here,
227
+ * once, and kept in main.js — or, with nobody to ask, refused with the line to add.
228
+ * Before the build, so a written name is built into the schema like any edit.
229
+ *
230
+ * @param {string} targetDir
231
+ * @param {{ args: string[], isPreview: boolean }} o
232
+ * @returns {Promise<boolean>} false → stop; the reason is printed
233
+ */
234
+ export async function settleFoundationName(targetDir, { args, isPreview }) {
235
+ let read
236
+ try {
237
+ read = await readFoundationName(targetDir)
238
+ } catch (err) {
239
+ error(err.message)
240
+ return false
241
+ }
242
+ const problem = checkFoundationName(read.name)
243
+ if (!problem) return true
244
+
245
+ const where = relative(process.cwd(), read.mainFile) || read.mainFile
246
+ const suggestion = suggestFoundationName(targetDir)
247
+ const reason = `${problem[0].toUpperCase()}${problem.slice(1)}.`
248
+
249
+ // Nobody to ask — a script, a preview (which writes nothing), or --json (whose
250
+ // stdout is a data channel a prompt would corrupt). Name the fix exactly.
251
+ if (isPreview || jsonMode || isNonInteractive(args)) {
252
+ error(`This foundation has no name it can register under. ${reason}`)
253
+ log(` Name it in ${colors.bright}${where}${colors.reset}, in the default export:`)
254
+ log(` ${colors.bright}name: '${suggestion || '<name>'}',${colors.reset}`)
255
+ log(` ${colors.dim}It registers as @<org>/<name>, and sites pin that name.${colors.reset}`)
256
+ return false
257
+ }
258
+
259
+ info(`This foundation needs a name to register under. ${reason}`)
260
+ const { name } = await prompts(
261
+ {
262
+ type: 'text',
263
+ name: 'name',
264
+ message: 'Foundation name (it registers as @<org>/<name>):',
265
+ initial: suggestion || '',
266
+ validate: (v) => checkFoundationName(String(v).trim()) || true
267
+ },
268
+ {
269
+ onCancel: () => {
270
+ log('\nRegister cancelled.')
271
+ process.exit(0)
272
+ }
273
+ }
274
+ )
275
+ const chosen = String(name || '').trim()
276
+ if (!chosen) return false
277
+
278
+ const written = writeFoundationName(read.mainFile, chosen, {
279
+ replace: read.source === 'main.js' ? read.name : null
280
+ })
281
+ if (!written.ok) {
282
+ error(`Could not write the name: ${written.reason}.`)
283
+ log(` Add it to the default export in ${colors.bright}${where}${colors.reset}: name: '${chosen}',`)
284
+ return false
285
+ }
286
+ // Read it back through the build's own rule — the text edit is only a means.
287
+ const again = await readFoundationName(targetDir).catch(() => null)
288
+ if (again?.name !== chosen) {
289
+ error(`${where} now names the foundation '${chosen}', but it still reads as "${again?.name}".`)
290
+ return false
291
+ }
292
+ success(`Named the foundation ${colors.bright}${chosen}${colors.reset} — kept in ${where}.`)
293
+ return true
294
+ }
295
+
212
296
  // Directories that are never foundation source. `dist` is the output we compare
213
297
  // against; the rest are tooling. Dotted entries are skipped wholesale.
214
298
  const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
@@ -404,14 +488,24 @@ async function runRegister(args = []) {
404
488
  : await resolveFoundationDir(args)
405
489
 
406
490
  // Scope: --scope flag, else package.json `uniweb.scope`, else (real submit
407
- // only) derived from login membership in the bootstrap below.
491
+ // only) derived from login membership in the bootstrap below. Either spelling,
492
+ // `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
408
493
  const pkgScope = readPkgScope(targetDir)
409
- let scope = scopeFlag || pkgScope
494
+ const givenScope = scopeFlag || pkgScope
495
+ let scope = publishScope(givenScope)
410
496
  let scopeSource = scopeFlag
411
497
  ? '--scope'
412
498
  : pkgScope
413
499
  ? 'package.json uniweb.scope'
414
500
  : null
501
+ // A scope that was GIVEN but names no org (`--scope @`) is refused, never read as
502
+ // absent: absent derives one from the login, which would register under an org the
503
+ // caller did not name.
504
+ if (givenScope && !scope) {
505
+ error(`Not an org scope: ${givenScope} (from ${scopeSource})`)
506
+ log(` ${colors.dim}Pass --scope @org.${colors.reset}`)
507
+ return { exitCode: 2 }
508
+ }
415
509
  const isPreview = !!output || dryRun
416
510
 
417
511
  // Each path supplies a different schema source: the standalone path discovers
@@ -433,8 +527,11 @@ async function runRegister(args = []) {
433
527
  return { exitCode: 2 }
434
528
  }
435
529
  } else {
436
- // Build-if-stale (mirrors `uniweb publish`): a missing or version-stale
437
- // dist/ gets (re)built before we read its schema. Preview paths
530
+ if (!(await settleFoundationName(targetDir, { args, isPreview }))) {
531
+ return { exitCode: 2 }
532
+ }
533
+ // Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
534
+ // (re)built before we read its schema. Preview paths
438
535
  // (--dry-run / -o) must not write to dist/, so they require a pre-built
439
536
  // foundation and say so instead of building.
440
537
  const { needs, reason } = foundationNeedsBuild(targetDir)
@@ -671,11 +768,17 @@ async function runRegister(args = []) {
671
768
  return { exitCode: 1 }
672
769
  }
673
770
  }
771
+ // The foundation's name as the registry holds it (`@org/name`) — read from the
772
+ // document just submitted, never composed again. See phase 2.
773
+ const registeredName = standalone
774
+ ? null
775
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info
776
+ ?.name
674
777
  if (!alreadyRegistered) {
675
778
  success(
676
779
  standalone
677
780
  ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
678
- : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
781
+ : `Registered ${registeredName}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
679
782
  )
680
783
  }
681
784
 
@@ -684,11 +787,13 @@ async function runRegister(args = []) {
684
787
  // only packages have no dist; --schema-only skips deliberately.
685
788
  if (!standalone && !args.includes('--schema-only')) {
686
789
  const distDir = join(targetDir, 'dist')
687
- // The registry's vocabulary is the SCOPED name (`@org/name`). A scoped
688
- // package name passes through; a bare one gets the chosen scope — the
689
- // same resolution the .uwx submission applied.
690
- const bareName = schema._self.name
691
- const name = bareName.startsWith('@') ? bareName : `${scope}/${bareName}`
790
+ // ⭐ The plan authorizes against the REGISTERED (name, version), so the name here
791
+ // is the one phase 1 registered — the `.uwx`'s own `info.name`, where a scoped
792
+ // package name passes through and a bare one gets the scope. ⛔ Not composed a
793
+ // second time: this line composed it from the raw scope until 2026-09-21, so
794
+ // `--scope std` registered `@std/src` and then asked to deliver `std/src`, which
795
+ // the registry refuses. One name, read from the one place it was decided.
796
+ const name = registeredName
692
797
  const version = schema._self.version
693
798
  info(
694
799
  `Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Extensions are technically a flavor of foundation (same build, same
22
22
  * package.json shape, distinguished only by `extension: true` in
23
- * src/foundation.js or `role: 'extension'` in the built schema). The
23
+ * main.js or `role: 'extension'` in the built schema). The
24
24
  * rename verb still keeps them on a separate subcommand because the
25
25
  * touch-point sets differ — `rename foundation` against an extension
26
26
  * would update the wrong things. Each subcommand guards its target
@@ -30,11 +30,13 @@
30
30
  * (target name already taken, target not found, folder collision,
31
31
  * type mismatch) we bail with a clear message and no partial state.
32
32
  *
33
- * Out of scope: registry side. The registered id (the scoped
34
- * `package.json::name` / `uniweb.id`) is independent of the workspace name and
35
- * stays untouched. There is no registry-rename flag — a registered version is
36
- * immutable; to change a foundation's registered identity, register it under
37
- * the new name (consuming sites repoint their `foundation:` ref).
33
+ * Out of scope: registry side. What a foundation registers as is `name` in its
34
+ * main.js, which is independent of the workspace name and stays untouched —
35
+ * except for a foundation with no main.js `name`, whose package name IS its name
36
+ * (`readFoundationName`), so renaming the package renames what it would register
37
+ * as. There is no registry-rename flag — a registered version is immutable; to
38
+ * change a foundation's registered name, change main.js's `name` and register
39
+ * again (consuming sites repoint their `foundation:` ref).
38
40
  *
39
41
  * Usage:
40
42
  * uniweb rename foundation <old> <new>
@@ -632,9 +634,10 @@ ${colors.bright}What rename extension does:${colors.reset}
632
634
  • Updates pnpm-workspace.yaml + package.json::workspaces.
633
635
 
634
636
  ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
635
- • Push to the registry. The registered id (package.json::uniweb.id) is
636
- independent. To register under a new name, set it and run \`${prefix} register\`
637
- (alias \`${prefix} release\`) — there is no rename flag.
637
+ • Push to the registry, or change what a foundation registers as — that is
638
+ \`name\` in its main.js, independent of the package name. To register under
639
+ a new name, change it there and run \`${prefix} register\` (alias
640
+ \`${prefix} release\`) — there is no rename flag.
638
641
 
639
642
  ${colors.bright}Examples:${colors.reset}
640
643
  ${prefix} rename foundation src marketing-src
@@ -28,7 +28,10 @@ import { resolveSiteDir } from './deploy.js'
28
28
  import { probeUnpushed } from '../backend/site-sync.js'
29
29
  import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
30
30
  import { readBackendState } from '@uniweb/build/uwx'
31
- import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
31
+ import {
32
+ resolveLocalFoundation,
33
+ foundationScopedName
34
+ } from '../backend/foundation-bring-along.js'
32
35
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
36
  import { checkFlags } from '../utils/flag-guard.js'
34
37
 
@@ -124,7 +127,8 @@ export async function status(args = []) {
124
127
  // site.yml ref. The digest compare is read-only — it never builds, so it
125
128
  // only fires when the local foundation is already built (dist present).
126
129
  const local = resolveLocalFoundation(siteDir, siteYml)
127
- const lookupName = local?.scopedName || fndScope
130
+ const lookupName =
131
+ (local && (await foundationScopedName(local.dir))) || fndScope
128
132
  if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
129
133
  if (local?.dir && fdnLatest?.digest) {
130
134
  const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-21T13:20:05.572Z",
3
+ "generatedAt": "2026-09-21T23:16:47.962Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.4.1",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.56.0",
13
+ "version": "0.58.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.29.6",
38
+ "version": "0.29.8",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.3",
58
+ "version": "0.19.4",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.2",
86
+ "version": "0.27.4",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.14.8",
119
+ "version": "0.16.0",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.9.27",
129
+ "version": "0.10.1",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
package/src/index.js CHANGED
@@ -53,6 +53,10 @@ import {
53
53
  mergeTemplateDependencies,
54
54
  getWorkspaceTemplateOutputs
55
55
  } from './utils/scaffold.js'
56
+ import {
57
+ normalizeFoundationName,
58
+ ensureFoundationName
59
+ } from './utils/foundation-name.js'
56
60
  import {
57
61
  detectPackageManager,
58
62
  detectWorkspacePm,
@@ -354,12 +358,16 @@ async function createFromPackageTemplates(
354
358
  // 2. Scaffold foundation (folder: src/, package name: src)
355
359
  // The folder name 'src' carries the meaning — a foundation is the site's
356
360
  // source code. The package name 'src' keeps it unique within the
357
- // workspace, since 'site' is taken by the site package.
361
+ // workspace, since 'site' is taken by the site package. Neither is the
362
+ // foundation's NAME — what it registers as — which is main.js's `name`:
363
+ // the project's, since a project's code is its foundation.
358
364
  onProgress?.('Creating foundation...')
365
+ const registryName = normalizeFoundationName(projectName)
359
366
  await scaffoldFoundation(
360
367
  join(projectDir, 'src'),
361
368
  {
362
369
  name: 'src',
370
+ registryName,
363
371
  projectName,
364
372
  isExtension: false
365
373
  },
@@ -380,10 +388,12 @@ async function createFromPackageTemplates(
380
388
  { onProgress, onWarning }
381
389
  )
382
390
 
383
- // 4. Apply starter content (unless creating a "none" project)
391
+ // 4. Apply starter content (unless creating a "none" project). Its main.js
392
+ // replaces the scaffolded one, so the name is given again.
384
393
  if (includeStarter) {
385
394
  onProgress?.('Adding starter content...')
386
395
  await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
396
+ ensureFoundationName(join(projectDir, 'src'), registryName)
387
397
  }
388
398
 
389
399
  success(`Created project: ${projectName}`)
@@ -475,15 +485,31 @@ async function createFromContentTemplate(
475
485
  )
476
486
 
477
487
  // 2. Scaffold and apply content for each package
488
+ const claimed = new Set() // names this project's foundations register as
478
489
  for (const pkg of placed) {
479
490
  const fullPath = join(projectDir, pkg.relativePath)
480
491
 
492
+ // A foundation's name — what it registers as — is the template's name for
493
+ // the package when that is one, else the project's (`src` and `foundation`
494
+ // name a folder); `<project>-<package>` if another package here took it. A
495
+ // template's own main.js `name` outranks all of them.
496
+ let registryName = null
497
+ if (pkg.type === 'foundation' || pkg.type === 'extension') {
498
+ registryName =
499
+ [
500
+ normalizeFoundationName(pkg.name) || normalizeFoundationName(projectName),
501
+ normalizeFoundationName(`${projectName}-${pkg.name}`)
502
+ ].find((n) => n && !claimed.has(n)) || null
503
+ if (registryName) claimed.add(registryName)
504
+ }
505
+
481
506
  if (pkg.type === 'foundation' || pkg.type === 'extension') {
482
507
  onProgress?.(`Creating ${pkg.type}: ${pkg.name}...`)
483
508
  await scaffoldFoundation(
484
509
  fullPath,
485
510
  {
486
511
  name: pkg.name,
512
+ registryName,
487
513
  projectName,
488
514
  isExtension: pkg.type === 'extension'
489
515
  },
@@ -534,6 +560,7 @@ async function createFromContentTemplate(
534
560
  renames: contentDir.renames
535
561
  }
536
562
  )
563
+ ensureFoundationName(fullPath, registryName)
537
564
  }
538
565
 
539
566
  // Merge template dependencies into package.json
@@ -1432,7 +1459,7 @@ ${colors.bright}Options:${colors.reset}
1432
1459
  --org @org Publish under @org (membership-gated; alias: --as-org). Read
1433
1460
  only on a site's FIRST publish — that create decides which org
1434
1461
  owns the site, and whose storage its assets are charged to.
1435
- Recorded as site.yml \$org and replayed, so it is never
1462
+ Recorded in sync.json and replayed, so it is never
1436
1463
  re-typed. Without it, you are asked once.
1437
1464
  --personal Create the site under your personal account, deliberately.
1438
1465
  Only needed on a first publish, and only to answer the owner