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.
@@ -2,93 +2,48 @@
2
2
  * Template resolver - parses template identifiers and determines source type
3
3
  */
4
4
 
5
- import { readFileSync, statSync } from 'node:fs'
6
- import { dirname, join } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
-
9
- const __dirname = dirname(fileURLToPath(import.meta.url))
5
+ import { loadFrameworkIndex } from '../versions.js'
10
6
 
11
7
  // Built-in templates (programmatic, not file-based)
12
8
  export const BUILTIN_TEMPLATES = ['blank', 'starter', 'none']
13
9
 
14
10
  /**
15
- * Load the official template metadata map — `{ id: { name, description,
16
- * tags } }`, keyed by template id.
17
- *
18
- * There are two sources of truth depending on where the CLI is running:
11
+ * THE OFFICIAL TEMPLATES THIS CLI SCAFFOLDS — one snapshot.
19
12
  *
20
- * 1. **Local dev inside the Uniweb monorepo** — the authoritative file
21
- * is `framework/templates/manifest.json`. Adding a new template
22
- * there makes it immediately reachable from any locally-run CLI
23
- * without republishing. This is the only path that matters for
24
- * `node scripts/framework/sandbox.js create`.
13
+ * `../framework-index.json` is written when the CLI is published: the
14
+ * templates' manifest (`{ id: { name, description, tags } }`) and, in
15
+ * `packages`, the `@uniweb/templates` version whose release holds their
16
+ * content. The interactive picker, `uniweb template list` and the download in
17
+ * `create` all read it, so the CLI offers exactly the templates it can fetch,
18
+ * and fetches the content released with the package versions it pins. A CLI
19
+ * goes on scaffolding the templates it was published with until it is
20
+ * updated — consistent, not newest.
25
21
  *
26
- * 2. **Published CLI (npm-installed)** — the monorepo isn't on disk, so
27
- * we read the vendored framework index at `../framework-index.json`,
28
- * which the publish pipeline rewrites just before `pnpm publish` runs,
29
- * copying the manifest's `templates` verbatim. The framework index is a
30
- * single snapshot file that also carries `@uniweb/*` package versions
31
- * (consumed by versions.js), so both the template list and the version
32
- * resolver share one source of truth.
22
+ * ⛔ Until 2026-10-08 a CLI run from a source checkout listed the templates
23
+ * repo's working manifest instead, and every CLI downloaded from the `latest`
24
+ * release. So a CLI could offer a template its release did not hold, and
25
+ * scaffold content newer than the packages it pinned: a template using a kit
26
+ * export the CLI's kit did not have yet failed to build.
33
27
  *
34
- * When both sources are available (local dev with a committed snapshot),
35
- * the live workspace manifest wins so newly-added templates are visible
36
- * without waiting for a CLI republish.
37
- *
38
- * Both files store `templates` as the same `{ id: { name, description,
39
- * tags } }` shape, so one map feeds both name-resolution
40
- * (OFFICIAL_TEMPLATES) and the interactive picker (buildTemplateChoices) —
41
- * neither hardcodes a list. (An earlier version returned only the keys and
42
- * left the `create` picker with its own hardcoded array, which silently
43
- * drifted out of sync whenever a template was added.)
28
+ * (An earlier version still hardcoded the picker's list, which silently
29
+ * drifted whenever a template was added — the reason no list is written here.)
44
30
  */
45
- function loadOfficialTemplateMap() {
46
- // Local dev: framework/templates/manifest.json relative to this file
47
- // at framework/cli/src/templates/resolver.js
48
- const workspaceManifest = join(
49
- __dirname,
50
- '..',
51
- '..',
52
- '..',
53
- 'templates',
54
- 'manifest.json'
55
- )
56
- const picked = tryReadTemplateMap(workspaceManifest)
57
- if (picked) return picked
58
-
59
- // Published CLI fallback: the framework index snapshot, one directory
60
- // up at framework/cli/src/framework-index.json.
61
- const indexPath = join(__dirname, '..', 'framework-index.json')
62
- const fromIndex = tryReadTemplateMap(indexPath)
63
- if (fromIndex) return fromIndex
64
-
65
- // If both sources fail, return an empty map rather than a stale
66
- // hardcoded list. An unknown template name then falls through to
67
- // the npm `@uniweb/template-<name>` lookup path, which is the
68
- // intended behavior for third-party templates.
69
- return {}
70
- }
71
-
72
- function tryReadTemplateMap(path) {
73
- try {
74
- if (!statSync(path).isFile()) return null
75
- const data = JSON.parse(readFileSync(path, 'utf8'))
76
- if (data && data.templates && typeof data.templates === 'object') {
77
- return data.templates
78
- }
79
- } catch {}
80
- return null
81
- }
31
+ const INDEX = loadFrameworkIndex()
82
32
 
83
- // Official template metadata keyed by id. Derived from manifest.json (local
84
- // dev) or framework-index.json (published CLI) at module load time — see
85
- // loadOfficialTemplateMap() for details. Read once per process; to reflect a
86
- // just-added template, restart the CLI or rerun the scaffolder.
87
- export const OFFICIAL_TEMPLATE_MAP = loadOfficialTemplateMap()
33
+ // Official template metadata keyed by id. Empty when the snapshot did not load:
34
+ // an unknown name then falls through to the npm `@uniweb/template-<name>`
35
+ // lookup, the path for third-party templates, rather than to a stale list.
36
+ export const OFFICIAL_TEMPLATE_MAP =
37
+ INDEX?.templates && typeof INDEX.templates === 'object' ? INDEX.templates : {}
88
38
 
89
39
  // Official template ids, e.g. ['marketing', 'docs', 'academic', …].
90
40
  export const OFFICIAL_TEMPLATES = Object.keys(OFFICIAL_TEMPLATE_MAP)
91
41
 
42
+ // The release the official templates download from — `v<@uniweb/templates
43
+ // version>`, the repo's tag — or null when the snapshot names none.
44
+ const TEMPLATES_VERSION = INDEX?.packages?.['@uniweb/templates']?.version
45
+ export const OFFICIAL_TEMPLATES_RELEASE = TEMPLATES_VERSION ? `v${TEMPLATES_VERSION}` : null
46
+
92
47
  // Built-in (programmatic) picker entries — not in the manifest; the CLI
93
48
  // generates these itself. "Blank" trails the official templates.
94
49
  const BUILTIN_LEAD_CHOICES = [
@@ -128,6 +83,24 @@ export function buildTemplateChoices() {
128
83
  return [...BUILTIN_LEAD_CHOICES, ...official, BLANK_CHOICE]
129
84
  }
130
85
 
86
+ /**
87
+ * How many options the `create` picker shows at once: all of them, when the
88
+ * terminal has the rows. prompts shows 10 by default and scrolls the rest, and
89
+ * past ten choices that put most of the official templates below the fold —
90
+ * a template you have to scroll to find reads as one that is not there.
91
+ *
92
+ * Seven rows are kept back: the question, and the highlighted choice's
93
+ * description, which wraps under it when it is long.
94
+ *
95
+ * @param {number} count - the picker's choices
96
+ * @param {number} [rows] - the terminal's height, when it reports one
97
+ * @returns {number}
98
+ */
99
+ export function templatePickerPageSize(count, rows = process.stdout.rows) {
100
+ const room = Number.isFinite(rows) && rows > 0 ? rows - 7 : count
101
+ return Math.max(10, Math.min(count, room))
102
+ }
103
+
131
104
  /**
132
105
  * Parse a template identifier and determine its source type
133
106
  *
@@ -1,84 +1,19 @@
1
1
  /**
2
- * Template validation - checks template.json and compatibility
2
+ * Template validation — reads template.json and finds the content directories
3
+ *
4
+ * ⛔ Two things this used to do are retired [2026-10-08]:
5
+ * - `compatible` (alias `uniweb`), a CLI version range a template declared. It
6
+ * was checked only when a caller passed the CLI's version, and none ever
7
+ * did. An official template now comes from the release its CLI was
8
+ * published with (resolver.js), so it cannot be newer than that CLI.
9
+ * - Unwrapping the layout of the v0.7 releases (`foundation/src/foundation.js`).
10
+ * The official templates are flat, and a CLI only downloads its own release.
3
11
  */
4
12
 
5
13
  import fs from 'node:fs/promises'
6
14
  import { existsSync } from 'node:fs'
7
15
  import path from 'node:path'
8
16
 
9
- /**
10
- * Simple semver satisfaction check
11
- * Supports: >=x.y.z, ^x.y.z, ~x.y.z, x.y.z, x.y.x, *, latest
12
- */
13
- export function satisfiesVersion(version, range) {
14
- if (!range || range === '*' || range === 'latest') {
15
- return true
16
- }
17
-
18
- const parseVersion = (v) => {
19
- const match = v.match(/^(\d+)\.(\d+)\.(\d+)/)
20
- if (!match) return null
21
- return {
22
- major: parseInt(match[1], 10),
23
- minor: parseInt(match[2], 10),
24
- patch: parseInt(match[3], 10)
25
- }
26
- }
27
-
28
- const current = parseVersion(version)
29
- if (!current) return true // Can't parse, assume compatible
30
-
31
- // Handle different range formats
32
- if (range.startsWith('>=')) {
33
- const min = parseVersion(range.slice(2))
34
- if (!min) return true
35
- if (current.major > min.major) return true
36
- if (current.major < min.major) return false
37
- if (current.minor > min.minor) return true
38
- if (current.minor < min.minor) return false
39
- return current.patch >= min.patch
40
- }
41
-
42
- if (range.startsWith('^')) {
43
- // ^x.y.z means >=x.y.z and <(x+1).0.0
44
- const min = parseVersion(range.slice(1))
45
- if (!min) return true
46
- if (current.major !== min.major)
47
- return current.major > min.major && min.major === 0
48
- if (current.minor > min.minor) return true
49
- if (current.minor < min.minor) return false
50
- return current.patch >= min.patch
51
- }
52
-
53
- if (range.startsWith('~')) {
54
- // ~x.y.z means >=x.y.z and <x.(y+1).0
55
- const min = parseVersion(range.slice(1))
56
- if (!min) return true
57
- if (current.major !== min.major) return false
58
- if (current.minor !== min.minor) return false
59
- return current.patch >= min.patch
60
- }
61
-
62
- // Exact version or x.y.x pattern
63
- if (range.includes('x')) {
64
- const parts = range.split('.')
65
- const min = parseVersion(range.replace(/x/g, '0'))
66
- if (!min) return true
67
- if (parts[0] !== 'x' && current.major !== min.major) return false
68
- if (parts[1] !== 'x' && current.minor !== min.minor) return false
69
- return true
70
- }
71
-
72
- // Exact match
73
- const exact = parseVersion(range)
74
- if (!exact) return true
75
- return (
76
- current.major === exact.major &&
77
- current.minor === exact.minor &&
78
- current.patch === exact.patch
79
- )
80
- }
81
-
82
17
  /**
83
18
  * Validation error with structured details
84
19
  */
@@ -95,21 +30,16 @@ export const ErrorCodes = {
95
30
  MISSING_TEMPLATE_JSON: 'MISSING_TEMPLATE_JSON',
96
31
  INVALID_TEMPLATE_JSON: 'INVALID_TEMPLATE_JSON',
97
32
  MISSING_CONTENT_DIR: 'MISSING_CONTENT_DIR',
98
- MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
99
- VERSION_MISMATCH: 'VERSION_MISMATCH'
33
+ MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD'
100
34
  }
101
35
 
102
36
  /**
103
37
  * Validate a template directory structure and metadata
104
38
  *
105
39
  * @param {string} templateRoot - Path to the template root (contains template.json)
106
- * @param {Object} options - Validation options
107
- * @param {string} options.uniwebVersion - Current Uniweb version to check compatibility
108
40
  * @returns {Object} Parsed and validated template metadata
109
41
  */
110
- export async function validateTemplate(templateRoot, options = {}) {
111
- const { uniwebVersion } = options
112
-
42
+ export async function validateTemplate(templateRoot) {
113
43
  // Check for template.json
114
44
  const metadataPath = path.join(templateRoot, 'template.json')
115
45
  if (!existsSync(metadataPath)) {
@@ -142,18 +72,6 @@ export async function validateTemplate(templateRoot, options = {}) {
142
72
  )
143
73
  }
144
74
 
145
- // Check version compatibility (support both `compatible` and `uniweb` fields)
146
- const versionRange = metadata.compatible || metadata.uniweb
147
- if (uniwebVersion && versionRange) {
148
- if (!satisfiesVersion(uniwebVersion, versionRange)) {
149
- throw new ValidationError(
150
- `Template requires Uniweb ${versionRange}, but current version is ${uniwebVersion}`,
151
- ErrorCodes.VERSION_MISMATCH,
152
- { required: versionRange, current: uniwebVersion }
153
- )
154
- }
155
- }
156
-
157
75
  // Format 2: content template — foundation/ and/or site/ directories alongside template.json
158
76
  const contentDirs = resolveContentDirs(templateRoot, metadata)
159
77
 
@@ -178,7 +96,7 @@ export async function validateTemplate(templateRoot, options = {}) {
178
96
  *
179
97
  * @param {string} templateRoot - Root of the template (contains template.json)
180
98
  * @param {Object} metadata - Parsed template.json
181
- * @returns {Array<Object>} Content directories: [{ type, name, dir, foundation?, renames? }]
99
+ * @returns {Array<Object>} Content directories: [{ type, name, dir, foundation? }]
182
100
  */
183
101
  export function resolveContentDirs(templateRoot, metadata) {
184
102
  const dirs = []
@@ -188,29 +106,19 @@ export function resolveContentDirs(templateRoot, metadata) {
188
106
  for (const pkg of metadata.packages) {
189
107
  const dir = path.join(templateRoot, pkg.name)
190
108
  if (existsSync(dir)) {
191
- const entry = {
109
+ dirs.push({
192
110
  type: pkg.type,
193
111
  name: pkg.name,
194
112
  dir,
195
113
  ...(pkg.foundation ? { foundation: pkg.foundation } : {})
196
- }
197
- if (entry.type === 'foundation' || entry.type === 'extension') {
198
- applyLegacyFoundationLayout(entry)
199
- }
200
- dirs.push(entry)
114
+ })
201
115
  }
202
116
  }
203
117
  } else {
204
118
  // Standard template: look for foundation/ and site/
205
119
  const foundationDir = path.join(templateRoot, 'foundation')
206
120
  if (existsSync(foundationDir)) {
207
- const entry = {
208
- type: 'foundation',
209
- name: 'foundation',
210
- dir: foundationDir
211
- }
212
- applyLegacyFoundationLayout(entry)
213
- dirs.push(entry)
121
+ dirs.push({ type: 'foundation', name: 'foundation', dir: foundationDir })
214
122
  }
215
123
 
216
124
  const siteDir = path.join(templateRoot, 'site')
@@ -221,83 +129,3 @@ export function resolveContentDirs(templateRoot, metadata) {
221
129
 
222
130
  return dirs
223
131
  }
224
-
225
- /**
226
- * Detect and unwrap the legacy foundation layout.
227
- *
228
- * Old templates (published in the `uniweb/templates` releases up to
229
- * v0.7.x) shipped foundation content nested one level deeper, with the
230
- * package source under `foundation/src/` and the user-authored
231
- * declarations file named `foundation.js`:
232
- *
233
- * <template>/foundation/src/foundation.js
234
- * <template>/foundation/src/sections/...
235
- * <template>/foundation/src/components/...
236
- *
237
- * The current layout is flat — the foundation package root contains
238
- * the source directly, and the declarations file is named `main.js`:
239
- *
240
- * <template>/foundation/main.js
241
- * <template>/foundation/sections/...
242
- *
243
- * The CLI scaffolds the new flat shape into the project's `src/`
244
- * directory, so an unmodified copy of an old-format template would
245
- * land at `src/src/foundation.js` (extra `src/` layer + old name).
246
- *
247
- * This helper detects the legacy marker (`<dir>/src/foundation.js`),
248
- * mutates the contentDir entry to point at the inner `src/` directory,
249
- * and records a top-level rename so `foundation.js` is written as
250
- * `main.js`. Once `uniweb/templates` is republished with the flat
251
- * layout, this branch becomes a no-op.
252
- */
253
- function applyLegacyFoundationLayout(entry) {
254
- const innerSrc = path.join(entry.dir, 'src')
255
- if (!existsSync(innerSrc)) return
256
-
257
- const legacyMain = path.join(innerSrc, 'foundation.js')
258
- const newMain = path.join(innerSrc, 'main.js')
259
- if (!existsSync(legacyMain) && !existsSync(newMain)) return
260
-
261
- entry.dir = innerSrc
262
- if (existsSync(legacyMain)) {
263
- entry.renames = { ...(entry.renames || {}), 'foundation.js': 'main.js' }
264
- }
265
- }
266
-
267
- /**
268
- * Get list of available templates in a templates directory
269
- *
270
- * @param {string} templatesDir - Path to directory containing templates
271
- * @returns {Array<Object>} List of template metadata
272
- */
273
- export async function listTemplates(templatesDir) {
274
- if (!existsSync(templatesDir)) {
275
- return []
276
- }
277
-
278
- const entries = await fs.readdir(templatesDir, { withFileTypes: true })
279
- const templates = []
280
-
281
- for (const entry of entries) {
282
- if (!entry.isDirectory()) continue
283
-
284
- const templatePath = path.join(templatesDir, entry.name)
285
- const metadataPath = path.join(templatePath, 'template.json')
286
-
287
- if (existsSync(metadataPath)) {
288
- try {
289
- const content = await fs.readFile(metadataPath, 'utf8')
290
- const metadata = JSON.parse(content)
291
- templates.push({
292
- id: entry.name,
293
- ...metadata,
294
- path: templatePath
295
- })
296
- } catch {
297
- // Skip invalid templates
298
- }
299
- }
300
- }
301
-
302
- return templates
303
- }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Exit once everything this process wrote to stdout and stderr has left it.
3
+ *
4
+ * ⛔ `process.exit()` does not wait for output a pipe has not taken yet. On macOS Node writes
5
+ * to a pipe asynchronously, and an exit drops whatever is still queued: `uniweb site list
6
+ * --json` reached a reading process as half a JSON document, cut at 8192 bytes, and a single
7
+ * 100 000-byte write followed by an exit delivered 65 536 — on stdout and on stderr alike
8
+ * (measured 2026-10-07). Into a terminal or a file, or on Linux, the writes are synchronous
9
+ * and nothing is lost, which is how it went unseen: the reader it bites is a script, `| jq`,
10
+ * or an agent capturing the output.
11
+ *
12
+ * ⭐ An empty write's callback runs once every write before it on that stream has been handed
13
+ * over, so waiting for one per stream is enough.
14
+ *
15
+ * Every exit in `index.js` goes through here — `test/exit-drain.test.js` holds it to that.
16
+ *
17
+ * @param {number} [code]
18
+ * @returns {Promise<never>}
19
+ */
20
+ export async function exitWhenDrained(code = 0) {
21
+ await Promise.all([process.stdout, process.stderr].map(drained))
22
+ process.exit(code)
23
+ }
24
+
25
+ function drained(stream) {
26
+ return new Promise((resolve) => {
27
+ if (!stream || stream.destroyed || stream.writableEnded) return resolve()
28
+ stream.write('', () => resolve())
29
+ })
30
+ }
@@ -155,11 +155,6 @@ export async function scaffoldSite(targetDir, context, options = {}) {
155
155
  * @param {string} targetDir - Target directory to overlay onto
156
156
  * @param {Object} context - Handlebars context for .hbs files
157
157
  * @param {Object} [options] - Processing options
158
- * @param {Object} [options.renames] - Top-level filename remapping
159
- * (e.g. `{ 'foundation.js': 'main.js' }`). Applied only at depth 0
160
- * so renames don't accidentally rewrite same-named files in nested
161
- * directories. Used to migrate legacy `foundation/foundation.js`
162
- * templates onto the new flat `src/main.js` layout.
163
158
  */
164
159
  export async function applyContent(
165
160
  contentDir,
@@ -221,11 +216,8 @@ const INHERITED_STATE = new Set(['sync.json', 'deploy.yml', '.uniweb'])
221
216
  /**
222
217
  * Recursively copy content files, skipping structural files.
223
218
  *
224
- * `depth` is tracked so the `renames` map (passed via options) only
225
- * applies at depth 0 — the top of the content directory. Without this
226
- * guard, a rename like `foundation.js → main.js` would also rewrite a
227
- * nested `sections/foo/foundation.js` if one existed, which is not the
228
- * intent.
219
+ * `depth` is tracked so the project-state names (INHERITED_STATE) are
220
+ * skipped only at the top of the content directory.
229
221
  */
230
222
  async function copyContentRecursive(
231
223
  sourceDir,
@@ -239,7 +231,6 @@ async function copyContentRecursive(
239
231
  await fs.mkdir(targetDir, { recursive: true })
240
232
 
241
233
  const entries = readdirSync(sourceDir, { withFileTypes: true })
242
- const renames = (depth === 0 && options.renames) || null
243
234
 
244
235
  for (const entry of entries) {
245
236
  const sourcePath = join(sourceDir, entry.name)
@@ -258,18 +249,10 @@ async function copyContentRecursive(
258
249
  )
259
250
  } else {
260
251
  // Determine the output filename (strip .hbs extension)
261
- let outputName = entry.name.endsWith('.hbs')
252
+ const outputName = entry.name.endsWith('.hbs')
262
253
  ? entry.name.slice(0, -4)
263
254
  : entry.name
264
255
 
265
- // Apply top-level rename (e.g. legacy `foundation.js` → `main.js`)
266
- if (
267
- renames &&
268
- Object.prototype.hasOwnProperty.call(renames, outputName)
269
- ) {
270
- outputName = renames[outputName]
271
- }
272
-
273
256
  // Skip structural files
274
257
  if (structuralFiles.has(outputName)) continue
275
258
  if (depth === 0 && INHERITED_STATE.has(outputName)) continue
@@ -57,36 +57,22 @@ function writeState(state) {
57
57
 
58
58
  /**
59
59
  * Print update notification to stderr (doesn't interfere with piped output).
60
- * `tone` controls the lead-in: 'soft' (default — trailing notice for finished
61
- * commands) vs 'eager' (leading notice for staleness-sensitive commands like
62
- * `create`, where the user is about to scaffold files from CLI-bundled
63
- * templates and a stale CLI means stale starter content).
60
+ * A trailing notice for a finished command; `create` and `clone` do not get
61
+ * one, they stop first (refuseOutdatedProjectStart).
64
62
  */
65
- function printNotification(current, latest, tone = 'soft') {
63
+ function printNotification(current, latest) {
66
64
  const yellow = '\x1b[33m'
67
65
  const cyan = '\x1b[36m'
68
66
  const dim = '\x1b[2m'
69
67
  const reset = '\x1b[0m'
70
68
  const updateCmd = globalCliUpdateCmd(detectGlobalCliPm())
71
69
  console.error('')
72
- if (tone === 'eager') {
73
- console.error(
74
- `${yellow}Heads up:${reset} this CLI is ${dim}${current}${reset}; latest is ${cyan}${latest}${reset}.`
75
- )
76
- console.error(
77
- `${dim}Templates ship with the CLI — consider updating first:${reset} ${updateCmd}`
78
- )
79
- console.error(
80
- `${dim}Or run a one-shot fresh:${reset} npx uniweb@latest <command>`
81
- )
82
- } else {
83
- console.error(
84
- `${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`
85
- )
86
- console.error(
87
- `${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`
88
- )
89
- }
70
+ console.error(
71
+ `${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`
72
+ )
73
+ console.error(
74
+ `${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`
75
+ )
90
76
  }
91
77
 
92
78
  /**
@@ -94,28 +80,78 @@ function printNotification(current, latest, tone = 'soft') {
94
80
  * version is known. No network fetch — only reads what `startUpdateCheck`
95
81
  * has previously cached. Returns true if a notification was printed.
96
82
  *
97
- * Two call sites today, with different tone needs:
98
- * - `create` (tone='eager'): loud leading notice — templates ship with
99
- * the CLI, the user is about to scaffold files, this matters.
100
- * - `--version` / `-v` (tone='soft'): brief trailing notice — the user
101
- * was already asking about version, mention staleness while we're
102
- * here. Goes to stderr so scripts capturing stdout aren't affected.
83
+ * For `--version` / `-v` off a terminal: the user was already asking about
84
+ * the version, so staleness is mentioned while we're here. Goes to stderr so
85
+ * scripts capturing stdout aren't affected.
103
86
  *
104
87
  * @param {string} currentVersion
105
- * @param {'eager'|'soft'} [tone='eager']
106
88
  * @returns {boolean} true if a notification was printed
107
89
  */
108
- export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
90
+ export function maybeNotifyFromCache(currentVersion) {
109
91
  const state = readState()
110
92
  if (!state.latestVersion) return false
111
93
  if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
112
- printNotification(currentVersion, state.latestVersion, tone)
94
+ printNotification(currentVersion, state.latestVersion)
113
95
  return true
114
96
  }
115
97
 
116
- // Old name preserved as alias — `create` calls it without a tone arg
117
- // and gets the eager default. Keeps that call site unchanged.
118
- export const maybeEagerNotification = maybeNotifyFromCache
98
+ /**
99
+ * Whether a release newer than `current` is published. Unknown (`latest` null:
100
+ * offline, or a slow registry) is not behind.
101
+ *
102
+ * @param {string} current
103
+ * @param {string|null} latest
104
+ * @returns {boolean}
105
+ */
106
+ export function isBehind(current, latest) {
107
+ return Boolean(latest) && compareSemver(latest, current) > 0
108
+ }
109
+
110
+ /** Arguments as a shell would need them typed again. */
111
+ export function shellJoin(args) {
112
+ return args
113
+ .map((a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`))
114
+ .join(' ')
115
+ }
116
+
117
+ /**
118
+ * Stop `create` or `clone` when a global CLI has been replaced by a newer
119
+ * release — before anything is written.
120
+ *
121
+ * ⭐ A PROJECT STARTS ON THE CLI THAT CREATES IT: its version table pins the
122
+ * `@uniweb/*` packages, and its official templates come from the release it was
123
+ * published with. Inside a project a global install only launches the project's
124
+ * own CLI; outside one, for `create` and `clone`, it does the work itself — so
125
+ * an outdated one starts the project outdated, and says nothing. `npx uniweb`
126
+ * with no version runs a global install too, which is how a 0.12 CLI went on
127
+ * offering the ten templates it knew while sixteen shipped (2026-10-08).
128
+ *
129
+ * Any newer release stops it, not only a newer minor: the version table moves
130
+ * whenever any package does — a patch of the CLI can pin a new breaking line of
131
+ * one — and the fix is one command either way. Unknown proceeds: this stops a
132
+ * known mistake, it does not require the network.
133
+ *
134
+ * @param {string} current - this CLI's version
135
+ * @param {string[]} argv - the command and its arguments, repeated in the fix
136
+ * @returns {Promise<boolean>} true when it refused, the reason printed
137
+ */
138
+ export async function refuseOutdatedProjectStart(current, argv) {
139
+ const latest = await getLatestVersion({ maxAgeMs: 60 * 60 * 1000 })
140
+ if (!isBehind(current, latest)) return false
141
+ const red = '\x1b[31m'
142
+ const cyan = '\x1b[36m'
143
+ const dim = '\x1b[2m'
144
+ const reset = '\x1b[0m'
145
+ console.error(`${red}✗${reset} This uniweb is ${current}; the latest release is ${latest}.`)
146
+ console.error(
147
+ ` ${dim}A project starts on the packages and templates of the CLI that creates it.${reset}`
148
+ )
149
+ console.error(` Run the latest: ${cyan}npx uniweb@latest ${shellJoin(argv)}${reset}`)
150
+ console.error(
151
+ ` Or update this one: ${cyan}${globalCliUpdateCmd(detectGlobalCliPm())}${reset}`
152
+ )
153
+ return true
154
+ }
119
155
 
120
156
  /**
121
157
  * Resolve the latest published CLI version WITHOUT printing anything.
@@ -188,12 +224,11 @@ export async function getLatestVersion({
188
224
  * @param {object} [opts]
189
225
  * @param {number} [opts.timeoutMs=1500] Network timeout. Slow / offline
190
226
  * calls return silently — never block the verb for long.
191
- * @param {'eager'|'soft'} [opts.tone='soft'] Notification copy.
192
227
  * @returns {Promise<boolean>} true if a notice was printed.
193
228
  */
194
229
  export async function fetchAndNotifyIfNewer(
195
230
  currentVersion,
196
- { timeoutMs = 1500, tone = 'soft' } = {}
231
+ { timeoutMs = 1500 } = {}
197
232
  ) {
198
233
  const controller = new AbortController()
199
234
  const timer = setTimeout(() => controller.abort(), timeoutMs)
@@ -216,7 +251,7 @@ export async function fetchAndNotifyIfNewer(
216
251
  // Refresh the cache so other code paths see this fresh result.
217
252
  writeState({ lastCheck: Date.now(), latestVersion: latest })
218
253
  if (compareSemver(latest, currentVersion) <= 0) return false
219
- printNotification(currentVersion, latest, tone)
254
+ printNotification(currentVersion, latest)
220
255
  return true
221
256
  }
222
257
 
package/src/versions.js CHANGED
@@ -240,8 +240,11 @@ function readWorkspaceVersion(packageName) {
240
240
  * Returns null if the file doesn't exist, is unparseable, or has a
241
241
  * schema version we don't understand. Callers treat null as "no
242
242
  * snapshot available" and fall back to their next source.
243
+ *
244
+ * Also the official templates' one source (`templates/resolver.js`): their
245
+ * roster, and the `@uniweb/templates` release their content downloads from.
243
246
  */
244
- function loadFrameworkIndex() {
247
+ export function loadFrameworkIndex() {
245
248
  const indexPath = join(__dirname, 'framework-index.json')
246
249
  try {
247
250
  const raw = readFileSync(indexPath, 'utf8')