uniweb 1.0.2 → 1.2.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.
@@ -0,0 +1,431 @@
1
+ // The workspace's packages — read as pnpm reads them, and written without touching
2
+ // anything else in the file.
3
+ //
4
+ // A workspace lists its packages as folder patterns: `packages:` in
5
+ // pnpm-workspace.yaml, or `workspaces` in package.json for npm and yarn. Every
6
+ // command that asks which packages a workspace holds — discovery, the workspace
7
+ // build, `update`'s survey, `dev`, `doctor` — asks here, and every command that adds
8
+ // or moves one edits the list through here.
9
+ //
10
+ // Patterns are read as pnpm reads them: `*`, `?` and `[...]` within one folder name,
11
+ // `**` for any depth, `{a,b}` for alternatives, and a leading `!` to exclude what the
12
+ // other patterns match. A folder a wildcard finds counts when it holds a package.json,
13
+ // as pnpm counts it — so `**` never sweeps in a `pages/` or `dist/` folder — and a
14
+ // wildcard never enters `node_modules` or a dot-folder. A folder listed by its own path
15
+ // counts if it exists: listing it says it is one, and a command that reads content only
16
+ // (`validate`) works on a folder with no manifest yet. Any other syntax is refused by
17
+ // name, never read as a path.
18
+ //
19
+ // ⛔ Until 2026-10-13 the CLI had two readers, each a partial copy of glob syntax:
20
+ // one read `x/*`, `*/x` and plain paths (discovery, the workspace build), the other
21
+ // only `x/*` and plain paths (`update`). `*/sites/*` matched nothing in either while
22
+ // pnpm read it, and `update` could not see the `*/src` that `add project` writes — it
23
+ // reported a stale foundation as aligned. And three writers (`add`, `rename`,
24
+ // `doctor --fix`) rewrote the file from its `packages` alone, deleting its comments
25
+ // and every other key, `onlyBuiltDependencies` and `allowBuilds` among them.
26
+ //
27
+ // Node built-ins, js-yaml and Handlebars only: the CLI's startup path reaches this
28
+ // module (`utils/workspace.js`), before any project is installed.
29
+
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
31
+ import { dirname, join } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+ import yaml from 'js-yaml'
34
+ import Handlebars from 'handlebars'
35
+
36
+ export const WORKSPACE_FILE = 'pnpm-workspace.yaml'
37
+
38
+ const TEMPLATE = join(
39
+ dirname(fileURLToPath(import.meta.url)),
40
+ '..',
41
+ '..',
42
+ 'templates',
43
+ 'workspace',
44
+ `${WORKSPACE_FILE}.hbs`
45
+ )
46
+
47
+ // ─── Reading ────────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * The package patterns a workspace declares, and the file they are read from:
51
+ * pnpm-workspace.yaml when the workspace has one, else package.json's `workspaces`.
52
+ *
53
+ * @param {string} rootDir
54
+ * @returns {{ source: string|null, patterns: string[] }}
55
+ */
56
+ export function readWorkspacePatterns(rootDir) {
57
+ const ymlPath = join(rootDir, WORKSPACE_FILE)
58
+ if (existsSync(ymlPath)) {
59
+ const doc = yaml.load(readFileSync(ymlPath, 'utf8'))
60
+ return { source: WORKSPACE_FILE, patterns: strings(doc?.packages) }
61
+ }
62
+ try {
63
+ const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'))
64
+ if (Array.isArray(pkg.workspaces)) return { source: 'package.json', patterns: strings(pkg.workspaces) }
65
+ } catch {
66
+ // No package.json, or not JSON — a workspace with no packages declared.
67
+ }
68
+ return { source: null, patterns: [] }
69
+ }
70
+
71
+ /**
72
+ * The workspace's packages: the folders its patterns name.
73
+ *
74
+ * @param {string} rootDir
75
+ * @returns {string[]} see {@link resolveWorkspacePackages}
76
+ */
77
+ export function workspacePackages(rootDir) {
78
+ return resolveWorkspacePackages(rootDir, readWorkspacePatterns(rootDir).patterns)
79
+ }
80
+
81
+ /**
82
+ * The folders `patterns` name under `rootDir`, as workspace-relative paths in `/`
83
+ * form, each once, in the order of the first pattern that names it — alphabetical
84
+ * within a pattern. A folder a wildcard finds counts only when it holds a
85
+ * package.json; one a pattern names by its own path counts if it exists.
86
+ *
87
+ * @param {string} rootDir
88
+ * @param {string[]} patterns
89
+ * @returns {string[]}
90
+ */
91
+ export function resolveWorkspacePackages(rootDir, patterns) {
92
+ const { include, exclude } = parsePatterns(patterns)
93
+ const seen = new Set()
94
+ const out = []
95
+ for (const pattern of include) {
96
+ const byWildcard = pattern.some((segment) => segment === '**' || hasMagic(segment))
97
+ for (const rel of expand(rootDir, pattern, [], 0, [])) {
98
+ // The root is the workspace itself, never one of its packages.
99
+ if (!rel || seen.has(rel)) continue
100
+ if (byWildcard && !existsSync(join(rootDir, rel, 'package.json'))) continue
101
+ if (exclude.some((ex) => matches(ex, rel.split('/')))) continue
102
+ seen.add(rel)
103
+ out.push(rel)
104
+ }
105
+ }
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Do `patterns` cover the folder at `relPath` — would it be one of the workspace's
111
+ * packages? Answered from the patterns alone, so it holds for a folder about to be
112
+ * created.
113
+ *
114
+ * @param {string[]} patterns
115
+ * @param {string} relPath - workspace-relative
116
+ * @returns {boolean}
117
+ */
118
+ export function patternsCover(patterns, relPath) {
119
+ const path = normalizePath(relPath)
120
+ if (!path) return false
121
+ const segments = path.split('/')
122
+ const { include, exclude } = parsePatterns(patterns)
123
+ return (
124
+ include.some((p) => matches(p, segments)) &&
125
+ !exclude.some((p) => matches(p, segments))
126
+ )
127
+ }
128
+
129
+ // ─── Writing ────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * A new pnpm-workspace.yaml listing `entries`, made from the workspace template —
133
+ * as `uniweb create` makes it, build settings included.
134
+ *
135
+ * @param {string[]} entries
136
+ * @returns {string}
137
+ */
138
+ export function renderWorkspaceFile(entries) {
139
+ return Handlebars.compile(readFileSync(TEMPLATE, 'utf8'))({ workspaceGlobs: entries })
140
+ }
141
+
142
+ /**
143
+ * pnpm-workspace.yaml's text with its `packages:` list changed by `edit`, and nothing
144
+ * else: comments, every other key and the layout stay as they were. `edit` may append
145
+ * entries or replace entries in place.
146
+ *
147
+ * The result is proven before it is returned — parsed again, it must hold the new
148
+ * list and every other key unchanged. Null when it does not, or when the list is not
149
+ * one this can edit in place: the caller says so rather than rewrite the file.
150
+ *
151
+ * @param {string} text
152
+ * @param {(list: string[]) => string[]} edit
153
+ * @returns {string|null}
154
+ */
155
+ export function editWorkspaceFilePackages(text, edit) {
156
+ let before
157
+ try {
158
+ before = yaml.load(text) ?? {}
159
+ } catch {
160
+ return null
161
+ }
162
+ if (typeof before !== 'object' || Array.isArray(before)) return null
163
+ const current = strings(before.packages)
164
+ const next = edit([...current])
165
+ const out = rewriteList(text, current, next)
166
+ if (out == null) return null
167
+
168
+ let after
169
+ try {
170
+ after = yaml.load(out) ?? {}
171
+ } catch {
172
+ return null
173
+ }
174
+ if (!sameValue(strings(after.packages), next)) return null
175
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
176
+ if (key !== 'packages' && !sameValue(before[key], after[key])) return null
177
+ }
178
+ return out
179
+ }
180
+
181
+ // ─── Patterns ───────────────────────────────────────────────────────────────
182
+
183
+ // `@(…)`, `+(…)`, `!(…)`, `*(…)`, `?(…)` — extended globs, which this does not read.
184
+ const EXTGLOB = /[@+!*?]\(/
185
+ const warned = new Set()
186
+
187
+ /** Positive and negative patterns as folder-name lists, `{a,b}` expanded. */
188
+ function parsePatterns(patterns) {
189
+ const include = []
190
+ const exclude = []
191
+ for (const raw of strings(patterns)) {
192
+ let pattern = raw.trim()
193
+ const negated = pattern.startsWith('!')
194
+ if (negated) pattern = pattern.slice(1)
195
+ if (EXTGLOB.test(pattern)) {
196
+ if (!warned.has(raw)) {
197
+ warned.add(raw)
198
+ process.stderr.write(
199
+ `⚠ uniweb does not read the workspace pattern "${raw}" (extended glob syntax) — ` +
200
+ `list the folders it stands for instead.\n`
201
+ )
202
+ }
203
+ continue
204
+ }
205
+ for (const one of expandBraces(pattern)) {
206
+ const path = normalizePath(one)
207
+ if (path) (negated ? exclude : include).push(path.split('/'))
208
+ }
209
+ }
210
+ return { include, exclude }
211
+ }
212
+
213
+ /** `a/{b,c}/d` → `a/b/d`, `a/c/d`; nested lists expand innermost first. */
214
+ function expandBraces(pattern) {
215
+ const m = /\{([^{}]*)\}/.exec(pattern)
216
+ if (!m) return [pattern]
217
+ const options = m[1].split(',')
218
+ // `{x}` lists nothing to choose between: a literal brace, as in pnpm.
219
+ if (options.length < 2) return [pattern]
220
+ const head = pattern.slice(0, m.index)
221
+ const tail = pattern.slice(m.index + m[0].length)
222
+ return options.flatMap((o) => expandBraces(head + o + tail))
223
+ }
224
+
225
+ /** `./a//b/` → `a/b`; `\` becomes `/`; `.` and `` are the root. */
226
+ function normalizePath(path) {
227
+ let s = String(path).replace(/\\/g, '/').replace(/\/{2,}/g, '/')
228
+ while (s.startsWith('./')) s = s.slice(2)
229
+ s = s.replace(/^\/+/, '').replace(/\/+$/, '')
230
+ return s === '.' ? '' : s
231
+ }
232
+
233
+ const hasMagic = (segment) => /[*?[]/.test(segment)
234
+
235
+ /** Can a wildcard step into this folder? Never `node_modules`, never a dot-folder. */
236
+ const wildcardMayEnter = (name) => !name.startsWith('.') && name !== 'node_modules'
237
+
238
+ const regexCache = new Map()
239
+
240
+ /** One folder-name pattern as a regex: `*`, `?` and `[...]`, everything else literal. */
241
+ function segmentRegex(segment) {
242
+ let re = regexCache.get(segment)
243
+ if (re) return re
244
+ let src = ''
245
+ for (let i = 0; i < segment.length; i++) {
246
+ const c = segment[i]
247
+ if (c === '*') src += '[^/]*'
248
+ else if (c === '?') src += '[^/]'
249
+ else if (c === '[' && segment.indexOf(']', i + 2) > i) {
250
+ const end = segment.indexOf(']', i + 2)
251
+ let body = segment.slice(i + 1, end).replace(/\\/g, '\\\\')
252
+ if (body.startsWith('!')) body = `^${body.slice(1)}`
253
+ src += `[${body}]`
254
+ i = end
255
+ } else src += c.replace(/[.+^$(){}|\\[\]]/g, '\\$&')
256
+ }
257
+ re = new RegExp(`^${src}$`)
258
+ regexCache.set(segment, re)
259
+ return re
260
+ }
261
+
262
+ function segmentMatches(segment, name) {
263
+ if (!hasMagic(segment)) return segment === name
264
+ // A wildcard does not match a dot-folder unless the pattern spells the dot.
265
+ if (name.startsWith('.') && !segment.startsWith('.')) return false
266
+ if (name === 'node_modules') return false
267
+ return segmentRegex(segment).test(name)
268
+ }
269
+
270
+ /** Does the folder-name list `path` match the pattern `pattern`? `**` is any depth. */
271
+ function matches(pattern, path, i = 0, j = 0) {
272
+ if (i === pattern.length) return j === path.length
273
+ if (pattern[i] === '**') {
274
+ for (let k = j; k <= path.length; k++) {
275
+ if (k > j && !wildcardMayEnter(path[k - 1])) break
276
+ if (matches(pattern, path, i + 1, k)) return true
277
+ }
278
+ return false
279
+ }
280
+ if (j === path.length || !segmentMatches(pattern[i], path[j])) return false
281
+ return matches(pattern, path, i + 1, j + 1)
282
+ }
283
+
284
+ /** The folders under `rootDir` that `pattern` names, found on disk. */
285
+ function expand(rootDir, pattern, base, i, out) {
286
+ if (i === pattern.length) {
287
+ out.push(base.join('/'))
288
+ return out
289
+ }
290
+ const segment = pattern[i]
291
+ const here = join(rootDir, ...base)
292
+ if (segment === '**') {
293
+ expand(rootDir, pattern, base, i + 1, out)
294
+ // Real folders only: a symlink back up the tree would never end.
295
+ for (const name of subfolders(here, { followLinks: false })) {
296
+ if (wildcardMayEnter(name)) expand(rootDir, pattern, [...base, name], i, out)
297
+ }
298
+ return out
299
+ }
300
+ if (!hasMagic(segment)) {
301
+ if (segment === '.') return expand(rootDir, pattern, base, i + 1, out)
302
+ if (isFolder(join(here, segment))) expand(rootDir, pattern, [...base, segment], i + 1, out)
303
+ return out
304
+ }
305
+ for (const name of subfolders(here)) {
306
+ if (segmentMatches(segment, name)) expand(rootDir, pattern, [...base, name], i + 1, out)
307
+ }
308
+ return out
309
+ }
310
+
311
+ function subfolders(dir, { followLinks = true } = {}) {
312
+ let entries
313
+ try {
314
+ entries = readdirSync(dir, { withFileTypes: true })
315
+ } catch {
316
+ return []
317
+ }
318
+ return entries
319
+ .filter(
320
+ (e) => e.isDirectory() || (followLinks && e.isSymbolicLink() && isFolder(join(dir, e.name)))
321
+ )
322
+ .map((e) => e.name)
323
+ .sort()
324
+ }
325
+
326
+ function isFolder(path) {
327
+ try {
328
+ return statSync(path).isDirectory()
329
+ } catch {
330
+ return false
331
+ }
332
+ }
333
+
334
+ // ─── The `packages:` list, as text ──────────────────────────────────────────
335
+
336
+ /**
337
+ * Apply `current` → `next` to the text of the `packages:` list: entries appended at
338
+ * its end, entries replaced on their own lines. Null for any other change, or a list
339
+ * written in a shape this does not edit (a flow list over several lines, an entry
340
+ * over several lines).
341
+ */
342
+ function rewriteList(text, current, next) {
343
+ const appended = next.length >= current.length && current.every((v, i) => v === next[i])
344
+ const replaced = next.length === current.length
345
+ if (!appended && !replaced) return null
346
+
347
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
348
+ const lines = text.split(/\r?\n/)
349
+ const key = lines.findIndex((l) => /^packages\s*:/.test(l))
350
+
351
+ // No `packages:` key yet — the list goes first, the rest of the file after it.
352
+ if (key < 0) {
353
+ if (!appended) return null
354
+ const items = next.map((v) => ` - ${formatEntry(v, '"')}`)
355
+ return ['packages:', ...items, '', text].join(eol)
356
+ }
357
+
358
+ const inline = lines[key].slice(lines[key].indexOf(':') + 1)
359
+ if (inline.trim() && !inline.trim().startsWith('#')) {
360
+ // A flow list on the key's own line: `packages: ["a", "b"]`.
361
+ const m = /^(\s*)\[(.*)\](\s*(#.*)?)$/.exec(inline)
362
+ if (!m) return null
363
+ lines[key] = `packages:${m[1]}[${next.map((v) => formatEntry(v, '"')).join(', ')}]${m[3]}`
364
+ return lines.join(eol)
365
+ }
366
+
367
+ // A block list: the `- ` lines that follow, until the next key.
368
+ const items = []
369
+ for (let i = key + 1; i < lines.length; i++) {
370
+ const l = lines[i]
371
+ if (/^\s*(#.*)?$/.test(l)) continue
372
+ if (/^\s*-(\s|$)/.test(l)) items.push(i)
373
+ else if (/^\s/.test(l)) return null // an entry over several lines
374
+ else break
375
+ }
376
+ if (items.length !== current.length) return null
377
+
378
+ for (let p = 0; p < current.length; p++) {
379
+ if (next[p] === current[p]) continue
380
+ const parts = splitItem(lines[items[p]])
381
+ if (!parts) return null
382
+ lines[items[p]] = `${parts.indent}- ${formatEntry(next[p], parts.quote)}${parts.tail}`
383
+ }
384
+ if (next.length > current.length) {
385
+ const last = items.length ? splitItem(lines[items[items.length - 1]]) : null
386
+ const indent = last ? last.indent : ' '
387
+ const quote = last ? last.quote : '"'
388
+ const added = next.slice(current.length).map((v) => `${indent}- ${formatEntry(v, quote)}`)
389
+ lines.splice((items.length ? items[items.length - 1] : key) + 1, 0, ...added)
390
+ }
391
+ return lines.join(eol)
392
+ }
393
+
394
+ /** A list line's indent, its value's quote style, and what follows the value. */
395
+ function splitItem(line) {
396
+ const m = /^(\s*)-\s+(.*)$/.exec(line)
397
+ if (!m) return null
398
+ const [, indent, rest] = m
399
+ const q = rest[0]
400
+ if (q === '"' || q === "'") {
401
+ let i = 1
402
+ for (; i < rest.length; i++) {
403
+ if (q === '"' && rest[i] === '\\') i++
404
+ else if (rest[i] === q) {
405
+ if (q === "'" && rest[i + 1] === "'") i++
406
+ else break
407
+ }
408
+ }
409
+ if (i >= rest.length) return null
410
+ return { indent, quote: q, tail: rest.slice(i + 1) }
411
+ }
412
+ const hash = rest.search(/\s#/)
413
+ return { indent, quote: '', tail: hash < 0 ? '' : rest.slice(hash) }
414
+ }
415
+
416
+ /** An entry as YAML, in the list's quote style — quoted whenever a bare one could misread. */
417
+ function formatEntry(value, quote) {
418
+ if (quote === "'") return `'${value.replace(/'/g, "''")}'`
419
+ if (quote === '' && /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(value)) return value
420
+ return JSON.stringify(value)
421
+ }
422
+
423
+ // ─── Helpers ────────────────────────────────────────────────────────────────
424
+
425
+ function strings(list) {
426
+ return Array.isArray(list) ? list.filter((v) => typeof v === 'string') : []
427
+ }
428
+
429
+ function sameValue(a, b) {
430
+ return JSON.stringify(a) === JSON.stringify(b)
431
+ }
@@ -6,10 +6,9 @@
6
6
  * Used by commands to auto-detect targets when run from workspace root.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
- import { readFile } from 'node:fs/promises'
9
+ import { existsSync, readFileSync } from 'node:fs'
11
10
  import { resolve, dirname, join } from 'node:path'
12
- import yaml from 'js-yaml'
11
+ import { workspacePackages } from './workspace-packages.js'
13
12
 
14
13
  // `classifyPackage` from @uniweb/build is loaded lazily — this module
15
14
  // is statically imported by index.js (for `findWorkspaceRoot`), and a
@@ -62,79 +61,19 @@ export function findWorkspaceRoot(startDir = process.cwd()) {
62
61
  }
63
62
 
64
63
  /**
65
- * Resolve workspace package patterns to actual directories
66
- * Handles patterns like: "foundation", "site", "foundations/*", "sites/*"
64
+ * Get workspace packages from pnpm-workspace.yaml or package.json workspaces —
65
+ * read as pnpm reads them (`workspacePackages`, ./workspace-packages.js).
66
+ *
67
+ * ⛔ Until 2026-10-13 this was its own partial reader of the patterns, which saw
68
+ * `x/*` and plain paths only: `update`, which surveys through it, could not see a
69
+ * project's `<project>/src` package as `add project` lists it — by a pattern whose
70
+ * first folder is a star — and reported a stale one as aligned.
67
71
  *
68
- * @param {string[]} patterns - Array of patterns from pnpm-workspace.yaml
69
- * @param {string} workspaceRoot - Workspace root directory
70
- * @returns {string[]} - Array of existing package directories (relative paths)
71
- */
72
- function resolvePatterns(patterns, workspaceRoot) {
73
- const packages = []
74
-
75
- for (const pattern of patterns) {
76
- // Remove quotes if present
77
- const cleanPattern = pattern.replace(/^["']|["']$/g, '')
78
-
79
- if (cleanPattern.endsWith('/*')) {
80
- // Glob pattern like "foundations/*" - list subdirectories
81
- const baseDir = cleanPattern.slice(0, -2)
82
- const fullPath = join(workspaceRoot, baseDir)
83
-
84
- if (existsSync(fullPath)) {
85
- try {
86
- const entries = readdirSync(fullPath, { withFileTypes: true })
87
- for (const entry of entries) {
88
- if (entry.isDirectory() && !entry.name.startsWith('.')) {
89
- packages.push(join(baseDir, entry.name))
90
- }
91
- }
92
- } catch {
93
- // Ignore read errors
94
- }
95
- }
96
- } else {
97
- // Direct path like "foundation" or "site"
98
- const fullPath = join(workspaceRoot, cleanPattern)
99
- if (existsSync(fullPath)) {
100
- packages.push(cleanPattern)
101
- }
102
- }
103
- }
104
-
105
- return packages
106
- }
107
-
108
- /**
109
- * Get workspace packages from pnpm-workspace.yaml or package.json workspaces
110
72
  * @param {string} workspaceRoot
111
73
  * @returns {Promise<string[]>} - Array of package directories (relative paths)
112
74
  */
113
75
  export async function getWorkspacePackages(workspaceRoot) {
114
- // Try pnpm-workspace.yaml first
115
- const configPath = join(workspaceRoot, 'pnpm-workspace.yaml')
116
- if (existsSync(configPath)) {
117
- const content = await readFile(configPath, 'utf-8')
118
- const config = yaml.load(content)
119
- if (config?.packages && Array.isArray(config.packages)) {
120
- return resolvePatterns(config.packages, workspaceRoot)
121
- }
122
- }
123
-
124
- // Fall back to package.json workspaces
125
- const pkgPath = join(workspaceRoot, 'package.json')
126
- if (existsSync(pkgPath)) {
127
- try {
128
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
129
- if (Array.isArray(pkg.workspaces)) {
130
- return resolvePatterns(pkg.workspaces, workspaceRoot)
131
- }
132
- } catch {
133
- // ignore
134
- }
135
- }
136
-
137
- return []
76
+ return workspacePackages(workspaceRoot)
138
77
  }
139
78
 
140
79
  /**
package/src/versions.js CHANGED
@@ -98,7 +98,7 @@ export const PNPM_VERSION = '11'
98
98
  * before Node 22 — pairing it with Node 20 fails at `pnpm install` with
99
99
  * `ERR_UNKNOWN_BUILTIN_MODULE` before the build is attempted. That was the
100
100
  * default until 2026-07-21, because the CI node major came from the
101
- * project's `engines.node` and the workspace template declares `>=20.19`.
101
+ * project's `engines.node` and the workspace template declared `>=20.19`.
102
102
  *
103
103
  * Verify with `npm view pnpm@<major> engines` when adding an entry.
104
104
  */
@@ -153,14 +153,16 @@ export function resolveCiPnpmVersion(rootPkg, installedMajor = null) {
153
153
  * @param {string|null|undefined} enginesNode — the project's `engines.node`.
154
154
  * @param {'pnpm'|'npm'|'yarn'} packageManager
155
155
  * @param {string} [pnpmVersion] — the resolved pnpm major, when pnpm.
156
- * @param {string} [fallback='20'] — used when engines.node is absent/unparseable.
156
+ * @param {string} [fallback='22'] — used when engines.node is absent/unparseable:
157
+ * the framework's own floor (`>=22.12`), which a scaffolded project inherits
158
+ * through its `@uniweb/*` dependencies without declaring one.
157
159
  * @returns {string} Node major, as a string for YAML interpolation.
158
160
  */
159
161
  export function resolveCiNodeVersion(
160
162
  enginesNode,
161
163
  packageManager,
162
164
  pnpmVersion = PNPM_VERSION,
163
- fallback = '20'
165
+ fallback = '22'
164
166
  ) {
165
167
  const match = enginesNode ? String(enginesNode).match(/(\d+)/) : null
166
168
  const declared = match ? Number(match[1]) : Number(fallback)