uniweb 0.87.3 → 0.88.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.
package/src/index.js CHANGED
@@ -41,7 +41,8 @@ import { template } from './commands/template.js'
41
41
  import {
42
42
  resolveTemplate,
43
43
  parseTemplateId,
44
- buildTemplateChoices
44
+ buildTemplateChoices,
45
+ templatePickerPageSize
45
46
  } from './templates/index.js'
46
47
  import { validateTemplate } from './templates/validator.js'
47
48
  import {
@@ -72,6 +73,7 @@ import {
72
73
  formatOptions
73
74
  } from './utils/interactive.js'
74
75
  import { findWorkspaceRoot } from './utils/workspace.js'
76
+ import { exitWhenDrained } from './utils/exit.js'
75
77
 
76
78
  // Colors for terminal output
77
79
  const colors = {
@@ -85,9 +87,8 @@ const colors = {
85
87
  }
86
88
 
87
89
  // Template choices for the interactive prompt — built-ins first, then every
88
- // official template from the shared manifest (so the list never drifts from
89
- // framework/templates/manifest.json), with "Blank" last. Composed by
90
- // buildTemplateChoices() in templates/resolver.js.
90
+ // official template in this CLI's snapshot (the list `create` can download),
91
+ // with "Blank" last. Composed by buildTemplateChoices() in templates/resolver.js.
91
92
  const TEMPLATE_CHOICES = buildTemplateChoices()
92
93
 
93
94
  // Files that may pre-exist in the target dir during `uniweb create .` and
@@ -223,7 +224,7 @@ function delegateToLocal(localCliPath) {
223
224
  [localCliPath, ...process.argv.slice(2)],
224
225
  { stdio: 'inherit' }
225
226
  )
226
- child.on('close', (code) => process.exit(code ?? 0))
227
+ child.on('close', (code) => exitWhenDrained(code ?? 0))
227
228
  child.on('error', reject)
228
229
  })
229
230
  }
@@ -320,7 +321,7 @@ async function importProjectCommand(modulePath) {
320
321
  log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
321
322
  }
322
323
 
323
- process.exit(1)
324
+ await exitWhenDrained(1)
324
325
  }
325
326
  }
326
327
 
@@ -554,11 +555,7 @@ async function createFromContentTemplate(
554
555
  contentDir.dir,
555
556
  fullPath,
556
557
  { projectName },
557
- {
558
- onProgress,
559
- onWarning,
560
- renames: contentDir.renames
561
- }
558
+ { onProgress, onWarning }
562
559
  )
563
560
  ensureFoundationName(fullPath, registryName)
564
561
  }
@@ -651,7 +648,7 @@ async function main() {
651
648
  // beside the new one it is ignored, so a script can carry both while CLIs of both ages run it.
652
649
  if (process.env.UNIWEB_REGISTER_URL && !process.env.UNIWEB_SERVER) {
653
650
  error('UNIWEB_REGISTER_URL is now UNIWEB_SERVER — rename it, and the command goes to the same backend.')
654
- process.exit(2)
651
+ await exitWhenDrained(2)
655
652
  }
656
653
 
657
654
  // Handle --version / -v
@@ -676,9 +673,9 @@ async function main() {
676
673
  const { fetchAndNotifyIfNewer, maybeNotifyFromCache } =
677
674
  await import('./utils/update-check.js')
678
675
  if (process.stdout.isTTY) {
679
- await fetchAndNotifyIfNewer(getCliVersion(), { tone: 'soft' })
676
+ await fetchAndNotifyIfNewer(getCliVersion())
680
677
  } else {
681
- maybeNotifyFromCache(getCliVersion(), 'soft')
678
+ maybeNotifyFromCache(getCliVersion())
682
679
  }
683
680
  } catch {
684
681
  /* ignore */
@@ -699,30 +696,36 @@ async function main() {
699
696
  // Commands that need @uniweb/build will get a helpful error via importProjectCommand().
700
697
  }
701
698
 
702
- // Start non-blocking update check for global installs.
699
+ // Update check for global installs.
703
700
  //
704
- // Two surfaces:
705
- // - showUpdateNotification (soft, trailing): printed at command end for
706
- // any verb. Doesn't interrupt the user's workflow.
707
- // - eager (loud, leading): printed BEFORE staleness-sensitive verbs do
708
- // their work. Today: only `create` (templates ship with the CLI, so
709
- // a stale CLI scaffolds stale starter content; the user needs to know
710
- // before files hit disk). Other verbs are insensitive — `deploy` etc.
711
- // are project-bound (delegated to local node_modules), and the
712
- // local-vs-global mismatch warning in delegateToLocal already covers
713
- // that case.
701
+ // - `create` / `clone` STOP when a newer release exists, before anything is
702
+ // written: a project starts on the packages and templates of the CLI that
703
+ // creates it, and a global install runs these two itself — there is no
704
+ // project CLI to delegate to yet (refuseOutdatedProjectStart). Their
705
+ // `--help` still prints.
706
+ // - every other verb gets a trailing notice (showUpdateNotification): they
707
+ // are project-bound (delegated to local node_modules) or do not depend on
708
+ // the version, and the local-vs-global mismatch warning in
709
+ // delegateToLocal covers the first case.
714
710
  let showUpdateNotification = () => {}
715
711
  if (global) {
712
+ let updateCheck = null
716
713
  try {
717
- const { startUpdateCheck, maybeEagerNotification } =
718
- await import('./utils/update-check.js')
719
- showUpdateNotification = startUpdateCheck(getCliVersion())
720
- if (command === 'create') {
721
- maybeEagerNotification(getCliVersion())
722
- }
714
+ updateCheck = await import('./utils/update-check.js')
723
715
  } catch {
724
716
  // Update check is optional — don't fail if the module is missing
725
717
  }
718
+ const startsProject = command === 'create' || command === 'clone'
719
+ const asksForHelp = args.slice(1).some((a) => a === '--help' || a === '-h')
720
+ if (
721
+ updateCheck &&
722
+ startsProject &&
723
+ !asksForHelp &&
724
+ (await updateCheck.refuseOutdatedProjectStart(getCliVersion(), args))
725
+ ) {
726
+ await exitWhenDrained(1)
727
+ }
728
+ if (updateCheck) showUpdateNotification = updateCheck.startUpdateCheck(getCliVersion())
726
729
  }
727
730
 
728
731
  // Show help
@@ -794,14 +797,14 @@ async function main() {
794
797
  if (command === 'doctor') {
795
798
  const { doctor } = await importProjectCommand('./commands/doctor.js')
796
799
  const result = await doctor(args.slice(1))
797
- process.exit(result?.errors > 0 ? 1 : 0)
800
+ await exitWhenDrained(result?.errors > 0 ? 1 : 0)
798
801
  }
799
802
 
800
803
  // Handle validate command (dynamic import — depends on @uniweb/build)
801
804
  if (command === 'validate') {
802
805
  const { validate } = await importProjectCommand('./commands/validate.js')
803
806
  const result = await validate(args.slice(1))
804
- process.exit(result?.exitCode ?? 0)
807
+ await exitWhenDrained(result?.exitCode ?? 0)
805
808
  }
806
809
 
807
810
  // Handle register command (dynamic import — depends on @uniweb/build).
@@ -810,35 +813,35 @@ async function main() {
810
813
  if (command === 'register' || command === 'release') {
811
814
  const { register } = await importProjectCommand('./commands/register.js')
812
815
  const result = await register(args.slice(1))
813
- process.exit(result?.exitCode ?? 0)
816
+ await exitWhenDrained(result?.exitCode ?? 0)
814
817
  }
815
818
 
816
819
  // Handle push command (dynamic import — depends on @uniweb/build)
817
820
  if (command === 'push') {
818
821
  const { push } = await importProjectCommand('./commands/push.js')
819
822
  const result = await push(args.slice(1))
820
- process.exit(result?.exitCode ?? 0)
823
+ await exitWhenDrained(result?.exitCode ?? 0)
821
824
  }
822
825
 
823
826
  // Handle pull command (dynamic import — depends on @uniweb/build)
824
827
  if (command === 'pull') {
825
828
  const { pull } = await importProjectCommand('./commands/pull.js')
826
829
  const result = await pull(args.slice(1))
827
- process.exit(result?.exitCode ?? 0)
830
+ await exitWhenDrained(result?.exitCode ?? 0)
828
831
  }
829
832
 
830
833
  // Handle sync command (refresh + push; both halves imported, never reimplemented)
831
834
  if (command === 'sync') {
832
835
  const { sync } = await importProjectCommand('./commands/sync.js')
833
836
  const result = await sync(args.slice(1))
834
- process.exit(result?.exitCode ?? 0)
837
+ await exitWhenDrained(result?.exitCode ?? 0)
835
838
  }
836
839
 
837
840
  // Handle refresh command (dynamic import — depends on @uniweb/build via pull)
838
841
  if (command === 'refresh') {
839
842
  const { refresh } = await importProjectCommand('./commands/refresh.js')
840
843
  const result = await refresh(args.slice(1))
841
- process.exit(result?.exitCode ?? 0)
844
+ await exitWhenDrained(result?.exitCode ?? 0)
842
845
  }
843
846
 
844
847
  // Handle forget — remove one backend's local records (sync.json, cache, deploy
@@ -847,14 +850,14 @@ async function main() {
847
850
  if (command === 'forget') {
848
851
  const { forget } = await importProjectCommand('./commands/forget.js')
849
852
  const result = await forget(args.slice(1))
850
- process.exit(result?.exitCode ?? 0)
853
+ await exitWhenDrained(result?.exitCode ?? 0)
851
854
  }
852
855
 
853
856
  // Handle status command (dynamic import — offline emit via @uniweb/build)
854
857
  if (command === 'status') {
855
858
  const { status } = await importProjectCommand('./commands/status.js')
856
859
  const result = await status(args.slice(1))
857
- process.exit(result?.exitCode ?? 0)
860
+ await exitWhenDrained(result?.exitCode ?? 0)
858
861
  }
859
862
 
860
863
  // Handle clone command (global — bootstraps a new project from a backend site;
@@ -864,7 +867,7 @@ async function main() {
864
867
  // `uniweb pull` after install.)
865
868
  if (command === 'clone') {
866
869
  const result = await clone(args.slice(1))
867
- process.exit(result?.exitCode ?? 0)
870
+ await exitWhenDrained(result?.exitCode ?? 0)
868
871
  }
869
872
 
870
873
  // Handle update command
@@ -899,7 +902,7 @@ async function main() {
899
902
  if (command === 'publish') {
900
903
  const { publish } = await importProjectCommand('./commands/publish.js')
901
904
  const result = await publish(args.slice(1))
902
- process.exit(result?.exitCode ?? 0)
905
+ await exitWhenDrained(result?.exitCode ?? 0)
903
906
  }
904
907
 
905
908
  // Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
@@ -946,7 +949,7 @@ async function main() {
946
949
  // dropped, the login would go to the default backend.
947
950
  if (loginArgs.some((a) => a === '--backend' || a.startsWith('--backend='))) {
948
951
  console.error('\x1b[31m✗\x1b[0m `--backend` is now `--server`: uniweb login --server <url>')
949
- process.exit(2)
952
+ await exitWhenDrained(2)
950
953
  }
951
954
  let apiBase
952
955
  try {
@@ -955,7 +958,7 @@ async function main() {
955
958
  console.error(
956
959
  `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --server http://localhost:8080`
957
960
  )
958
- process.exit(2)
961
+ await exitWhenDrained(2)
959
962
  }
960
963
  await runRegistryLogin({ apiBase, args: loginArgs })
961
964
  return
@@ -971,7 +974,7 @@ async function main() {
971
974
  console.error(
972
975
  `\x1b[31m✗\x1b[0m \`uniweb logout\` takes no options (got ${stray}) — it logs you out of the backend you are logged in to.`
973
976
  )
974
- process.exit(2)
977
+ await exitWhenDrained(2)
975
978
  }
976
979
  const { clearRegistryAuth } = await import('./utils/registry-auth.js')
977
980
  const { loggedInOrigin } = await import('./utils/config.js')
@@ -994,12 +997,9 @@ async function main() {
994
997
  if (command === 'site') {
995
998
  const { site } = await import('./commands/site.js')
996
999
  const result = await site(args.slice(1))
997
- // ⛔ EXIT ONLY ONCE STDOUT HAS DRAINED. To a pipe Node writes asynchronously, so an exit
998
- // straight after a long list cut it at 8192 bytes — measured 2026-10-07: `site list --json`
999
- // of 79 sites reached a reading process as half a JSON document, while the same command
1000
- // into a file (synchronous) was whole. Pinned by test/site-list-pipe.test.js.
1001
- await new Promise((resolve) => process.stdout.write('', resolve))
1002
- process.exit(result?.exitCode ?? 0)
1000
+ // Its long list is where a bare exit was first caught cutting a pipe short — pinned by
1001
+ // test/site-list-pipe.test.js; why every exit waits: utils/exit.js.
1002
+ await exitWhenDrained(result?.exitCode ?? 0)
1003
1003
  }
1004
1004
 
1005
1005
  // Handle org command (new-backend orgs/units — publish-scope management)
@@ -1030,15 +1030,15 @@ async function main() {
1030
1030
 
1031
1031
  // Handle template command
1032
1032
  if (command === 'template') {
1033
- await template(args.slice(1))
1034
- return
1033
+ const result = await template(args.slice(1))
1034
+ await exitWhenDrained(result?.exitCode ?? 0)
1035
1035
  }
1036
1036
 
1037
1037
  // Handle create command
1038
1038
  if (command !== 'create') {
1039
1039
  error(`Unknown command: ${command}`)
1040
1040
  showHelp()
1041
- process.exit(1)
1041
+ await exitWhenDrained(1)
1042
1042
  }
1043
1043
 
1044
1044
  title('Uniweb Project Generator')
@@ -1053,7 +1053,7 @@ async function main() {
1053
1053
  log(
1054
1054
  ` ${colors.cyan}uniweb add foundation --from <template>${colors.reset}\n`
1055
1055
  )
1056
- process.exit(1)
1056
+ await exitWhenDrained(1)
1057
1057
  }
1058
1058
 
1059
1059
  // Parse arguments
@@ -1081,7 +1081,7 @@ async function main() {
1081
1081
  parseTemplateId(templateType)
1082
1082
  } catch (err) {
1083
1083
  error(`Invalid template: ${err.message}`)
1084
- process.exit(1)
1084
+ await exitWhenDrained(1)
1085
1085
  }
1086
1086
  }
1087
1087
 
@@ -1127,7 +1127,7 @@ async function main() {
1127
1127
  `Could not derive a valid project name from the current directory ("${dirName}").`
1128
1128
  )
1129
1129
  log(`Re-run with ${colors.cyan}--name=<your-name>${colors.reset}.`)
1130
- process.exit(1)
1130
+ await exitWhenDrained(1)
1131
1131
  }
1132
1132
  projectName = slug
1133
1133
  if (slug !== dirName) {
@@ -1144,7 +1144,7 @@ async function main() {
1144
1144
  if (nonInteractive && !projectName) {
1145
1145
  error(`Missing project name.\n`)
1146
1146
  log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
1147
- process.exit(1)
1147
+ await exitWhenDrained(1)
1148
1148
  }
1149
1149
 
1150
1150
  // Non-interactive: default to starter when no template specified
@@ -1172,7 +1172,7 @@ async function main() {
1172
1172
  {
1173
1173
  onCancel: () => {
1174
1174
  log('\nScaffolding cancelled.')
1175
- process.exit(0)
1175
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1176
1176
  }
1177
1177
  }
1178
1178
  )
@@ -1181,7 +1181,7 @@ async function main() {
1181
1181
 
1182
1182
  if (!projectName) {
1183
1183
  error('Missing project name')
1184
- process.exit(1)
1184
+ await exitWhenDrained(1)
1185
1185
  }
1186
1186
 
1187
1187
  // Prompt for template if not specified via --template or --blank
@@ -1192,12 +1192,13 @@ async function main() {
1192
1192
  name: 'template',
1193
1193
  message: 'Template:',
1194
1194
  choices: TEMPLATE_CHOICES,
1195
- initial: 1
1195
+ initial: 1,
1196
+ optionsPerPage: templatePickerPageSize(TEMPLATE_CHOICES.length)
1196
1197
  },
1197
1198
  {
1198
1199
  onCancel: () => {
1199
1200
  log('\nScaffolding cancelled.')
1200
- process.exit(0)
1201
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1201
1202
  }
1202
1203
  }
1203
1204
  )
@@ -1219,7 +1220,7 @@ async function main() {
1219
1220
 
1220
1221
  if (!inPlace && existsSync(projectDir)) {
1221
1222
  error(`Directory already exists: ${projectName}`)
1222
- process.exit(1)
1223
+ await exitWhenDrained(1)
1223
1224
  }
1224
1225
 
1225
1226
  if (inPlace) {
@@ -1245,7 +1246,7 @@ async function main() {
1245
1246
  log(
1246
1247
  `Move or remove them, then re-run ${colors.cyan}uniweb create .${colors.reset}.`
1247
1248
  )
1248
- process.exit(1)
1249
+ await exitWhenDrained(1)
1249
1250
  }
1250
1251
  }
1251
1252
 
@@ -1292,7 +1293,7 @@ async function main() {
1292
1293
  )
1293
1294
 
1294
1295
  // Validate and apply as format 2 content template
1295
- const metadata = await validateTemplate(resolved.path, {})
1296
+ const metadata = await validateTemplate(resolved.path)
1296
1297
 
1297
1298
  try {
1298
1299
  await createFromContentTemplate(
@@ -1320,7 +1321,7 @@ async function main() {
1320
1321
  log(
1321
1322
  ` • Try the starter template instead: ${colors.cyan}uniweb create ${projectName} --template starter${colors.reset}`
1322
1323
  )
1323
- process.exit(1)
1324
+ await exitWhenDrained(1)
1324
1325
  }
1325
1326
  }
1326
1327
 
@@ -1506,7 +1507,8 @@ ${colors.bright}Usage:${colors.reset}
1506
1507
 
1507
1508
  ${colors.bright}Options:${colors.reset}
1508
1509
  --template <type> Project template (default: starter)
1509
- Built-in: starter, none, marketing
1510
+ Built-in: starter, none, blank
1511
+ Official: marketing, docs, … (\`uniweb template list\`)
1510
1512
  Local: ./path/to/template
1511
1513
  npm: @scope/template-name
1512
1514
  GitHub: github:user/repo or https://github.com/user/repo
@@ -1666,12 +1668,13 @@ ${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
1666
1668
  GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
1667
1669
 
1668
1670
  ${colors.bright}Examples:${colors.reset}
1669
- uniweb add project docs # Create docs/foundation/ + docs/site/
1671
+ uniweb add project docs # Create docs/src/ + docs/site/
1670
1672
  uniweb add project docs --from academic # Co-located pair + academic content
1671
- uniweb add foundation # Create ./foundation/ at root
1672
- uniweb add foundation ui # Create ./foundations/ui/
1673
+ uniweb add foundation # Create ./src/ at root
1674
+ uniweb add foundation ui # Create ./ui/ — the folder you name
1675
+ uniweb add foundation foundations/ui # Create ./foundations/ui/
1673
1676
  uniweb add site # Create ./site/ at root
1674
- uniweb add site blog --foundation marketing # Create ./sites/blog/ wired to marketing
1677
+ uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
1675
1678
  uniweb add extension effects --site site # Create ./extensions/effects/
1676
1679
  uniweb add section Hero # Create Hero section type
1677
1680
  uniweb add section Hero --foundation ui # Target specific foundation
@@ -1679,7 +1682,7 @@ ${colors.bright}Examples:${colors.reset}
1679
1682
  uniweb add section Pricing --starter # Scaffold Pricing + content that fills it
1680
1683
  uniweb add section Hero --starter --preset split # Frontmatter it with the 'split' preset
1681
1684
  uniweb add section Hero --starter --json # The structure + ProseMirror, for a script
1682
- uniweb add foundation --project docs # Create ./docs/foundation/ (co-located)
1685
+ uniweb add foundation --project docs # Create ./docs/src/ (co-located)
1683
1686
  uniweb add site --project docs # Create ./docs/site/ (co-located)
1684
1687
  uniweb add ci # Pick a host, add a deploy workflow
1685
1688
  uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
@@ -1971,18 +1974,12 @@ ${colors.bright}Usage:${colors.reset}
1971
1974
 
1972
1975
  ${colors.bright}What \`list\` reports:${colors.reset}
1973
1976
  The roster ${colors.bright}this CLI ships with${colors.reset} — a snapshot baked in at publish time,
1974
- not a live fetch. That is the right question when the next step is resolving
1975
- one of those names with the same CLI, which is why \`--json\` stamps
1976
- \`cliVersion\`. A template newer than your CLI will not be listed.
1977
+ not a live fetch. \`create\` downloads these templates from the release the same
1978
+ snapshot names, which is why \`--json\` stamps \`cliVersion\`. A template newer than
1979
+ your CLI is not listed: \`npx uniweb@latest template list\` shows the current roster.
1977
1980
 
1978
1981
  It is the ${colors.bright}official${colors.reset} roster, not the set of resolvable names: an unknown
1979
1982
  name is not fatal, since \`create\` falls through to npm \`@uniweb/template-<name>\`.
1980
-
1981
- ${colors.bright}Reserved:${colors.reset}
1982
- \`uniweb template register\` is not available on the new backend yet. Submitting
1983
- a site as a cloud template was retired with the legacy backend; when rebuilt, a
1984
- template is REGISTERED (like a foundation) — \`publish\` is for sites only.
1985
- Scaffolding FROM a template is unaffected: \`uniweb create --template <name>\`.
1986
1983
  `,
1987
1984
  docs: `
1988
1985
  ${colors.cyan}${colors.bright}uniweb docs${colors.reset} ${colors.dim}— Generate component documentation${colors.reset}
@@ -2095,6 +2092,7 @@ ${colors.bright}Commands:${colors.reset}
2095
2092
  inspect <path> Inspect parsed content shape of a markdown file or folder
2096
2093
  docs Generate component documentation
2097
2094
  families List the standard section families (for meta.js family:)
2095
+ template list List the official templates (for create --template)
2098
2096
  doctor Diagnose project configuration issues
2099
2097
  validate Check your content against your foundation's data schemas
2100
2098
  update Align workspace deps + AGENTS.md to the running CLI
@@ -2216,9 +2214,9 @@ ${colors.bright}Examples:${colors.reset}
2216
2214
  cd my-project
2217
2215
  uniweb add project docs # Add docs/src/ + docs/site/
2218
2216
  uniweb add project docs --from academic # Co-located pair + academic content
2219
- uniweb add marketing # Add marketing/ at root
2220
- uniweb add site blog --foundation marketing # Add site/ wired to marketing
2221
- uniweb add extension effects --site site # Add effects/ at root
2217
+ uniweb add foundation marketing # Add marketing/ at root
2218
+ uniweb add site blog --foundation marketing # Add blog/ wired to marketing
2219
+ uniweb add extension effects --site site # Add extensions/effects/
2222
2220
 
2223
2221
  uniweb build
2224
2222
  uniweb build --target src # Build src/ package
@@ -2231,7 +2229,7 @@ ${colors.bright}Install:${colors.reset}
2231
2229
  }
2232
2230
 
2233
2231
  // Run CLI
2234
- main().catch((err) => {
2232
+ main().catch(async (err) => {
2235
2233
  error(err.message)
2236
- process.exit(1)
2234
+ await exitWhenDrained(1)
2237
2235
  })
@@ -14,43 +14,32 @@ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.
14
14
  const TEMPLATES_REPO = 'uniweb/templates'
15
15
  const GITHUB_API = 'https://api.github.com'
16
16
 
17
- // Cache for manifest (avoid re-fetching in same session)
18
- let manifestCache = null
19
-
20
17
  /**
21
- * Fetch the manifest.json from the latest release
18
+ * Fetch manifest.json from one release of the official templates.
19
+ *
20
+ * ⛔ Always a named release — the one the CLI was published with — and never
21
+ * `latest`: the latest templates can be newer than the packages this CLI pins
22
+ * (templates/resolver.js, OFFICIAL_TEMPLATES_RELEASE).
22
23
  *
23
24
  * @param {Object} options - Fetch options
24
- * @param {string} options.version - Specific version tag (default: latest)
25
+ * @param {string} options.version - The release tag, e.g. `v0.18.4`
25
26
  * @param {Function} options.onProgress - Progress callback
26
- * @returns {Promise<Object>} { version, templates, downloadUrl }
27
+ * @returns {Promise<Object>} { version, templates, downloadUrlBase }
27
28
  */
28
29
  export async function fetchManifest(options = {}) {
29
30
  const { version, onProgress } = options
30
-
31
- // Return cached manifest if available and no specific version requested
32
- if (manifestCache && !version) {
33
- return manifestCache
34
- }
31
+ if (!version) throw new Error('fetchManifest needs the release tag to read')
35
32
 
36
33
  onProgress?.('Fetching template manifest...')
37
34
 
38
- // Get release info
39
- const releaseUrl = version
40
- ? `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/tags/${version}`
41
- : `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/latest`
42
-
43
- const releaseResponse = await fetchWithRetry(releaseUrl, {
44
- headers: getGitHubHeaders()
45
- })
35
+ const releaseResponse = await fetchWithRetry(
36
+ `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/tags/${version}`,
37
+ { headers: getGitHubHeaders() }
38
+ )
46
39
 
47
40
  if (!releaseResponse.ok) {
48
41
  if (releaseResponse.status === 404) {
49
- throw new Error(
50
- version
51
- ? `Release ${version} not found for ${TEMPLATES_REPO}`
52
- : `No releases found for ${TEMPLATES_REPO}`
53
- )
42
+ throw new Error(`Release ${version} not found for ${TEMPLATES_REPO}`)
54
43
  }
55
44
  await handleGitHubError(releaseResponse)
56
45
  }
@@ -80,28 +69,20 @@ export async function fetchManifest(options = {}) {
80
69
 
81
70
  const manifest = await manifestResponse.json()
82
71
 
83
- // Build result with download URL base
84
- const result = {
72
+ return {
85
73
  version: release.tag_name,
86
74
  templates: manifest.templates || {},
87
75
  // Base URL for downloading template tarballs
88
76
  downloadUrlBase: `https://github.com/${TEMPLATES_REPO}/releases/download/${release.tag_name}`
89
77
  }
90
-
91
- // Cache if this was a "latest" fetch
92
- if (!version) {
93
- manifestCache = result
94
- }
95
-
96
- return result
97
78
  }
98
79
 
99
80
  /**
100
- * Fetch a specific template from GitHub releases
81
+ * Fetch a template from one release of the official templates
101
82
  *
102
83
  * @param {string} name - Template name (e.g., 'marketing')
103
84
  * @param {Object} options - Fetch options
104
- * @param {string} options.version - Specific version tag (default: latest)
85
+ * @param {string} options.version - The release tag, e.g. `v0.18.4`
105
86
  * @param {Function} options.onProgress - Progress callback
106
87
  * @returns {Promise<Object>} { tempDir, version, metadata }
107
88
  */
@@ -166,33 +147,6 @@ export async function fetchOfficialTemplate(name, options = {}) {
166
147
  }
167
148
  }
168
149
 
169
- /**
170
- * List available templates from the latest release
171
- *
172
- * @param {Object} options - Fetch options
173
- * @param {Function} options.onProgress - Progress callback
174
- * @returns {Promise<Array>} List of template metadata
175
- */
176
- export async function listOfficialTemplates(options = {}) {
177
- try {
178
- const manifest = await fetchManifest(options)
179
- return Object.entries(manifest.templates).map(([id, info]) => ({
180
- id,
181
- ...info
182
- }))
183
- } catch {
184
- // Return empty list if can't fetch
185
- return []
186
- }
187
- }
188
-
189
- /**
190
- * Clear the manifest cache
191
- */
192
- export function clearManifestCache() {
193
- manifestCache = null
194
- }
195
-
196
150
  /**
197
151
  * Get GitHub API headers
198
152
  */
@@ -12,15 +12,13 @@ import {
12
12
  getTemplateDisplayName,
13
13
  BUILTIN_TEMPLATES,
14
14
  OFFICIAL_TEMPLATES,
15
- buildTemplateChoices
15
+ OFFICIAL_TEMPLATES_RELEASE,
16
+ buildTemplateChoices,
17
+ templatePickerPageSize
16
18
  } from './resolver.js'
17
19
  import { fetchNpmTemplate } from './fetchers/npm.js'
18
20
  import { fetchGitHubTemplate } from './fetchers/github.js'
19
- import {
20
- fetchOfficialTemplate,
21
- listOfficialTemplates
22
- } from './fetchers/release.js'
23
- import { validateTemplate } from './validator.js'
21
+ import { fetchOfficialTemplate } from './fetchers/release.js'
24
22
 
25
23
  /**
26
24
  * Resolve a template identifier and return the template path
@@ -61,12 +59,20 @@ export async function resolveTemplate(identifier, options = {}) {
61
59
  }
62
60
 
63
61
  /**
64
- * Resolve an official template from GitHub releases
62
+ * Resolve an official template from the release this CLI was published with —
63
+ * never `latest`, so its content matches the package versions the scaffold pins
64
+ * (resolver.js, OFFICIAL_TEMPLATES_RELEASE).
65
65
  */
66
66
  async function resolveOfficialTemplate(name, options = {}) {
67
67
  const { onProgress } = options
68
68
 
69
+ if (!OFFICIAL_TEMPLATES_RELEASE) {
70
+ throw new Error(
71
+ "This CLI's framework index names no @uniweb/templates release to download from."
72
+ )
73
+ }
69
74
  const { tempDir, baseTempDir, version } = await fetchOfficialTemplate(name, {
75
+ version: OFFICIAL_TEMPLATES_RELEASE,
70
76
  onProgress
71
77
  })
72
78
 
@@ -173,35 +179,12 @@ async function resolveLocalTemplate(templatePath, options = {}) {
173
179
  }
174
180
  }
175
181
 
176
- /**
177
- * List all available templates
178
- */
179
- export async function listAvailableTemplates() {
180
- const templates = []
181
-
182
- // Official templates from GitHub releases
183
- try {
184
- const official = await listOfficialTemplates()
185
- for (const t of official) {
186
- templates.push({
187
- type: 'official',
188
- id: t.id,
189
- name: t.name || t.id,
190
- description: t.description || ''
191
- })
192
- }
193
- } catch {
194
- // Ignore errors - templates just won't be listed
195
- }
196
-
197
- return templates
198
- }
199
-
200
182
  // Re-export for convenience
201
183
  export {
202
184
  parseTemplateId,
203
185
  getTemplateDisplayName,
204
186
  BUILTIN_TEMPLATES,
205
187
  OFFICIAL_TEMPLATES,
206
- buildTemplateChoices
188
+ buildTemplateChoices,
189
+ templatePickerPageSize
207
190
  }