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/README.md +1 -1
- package/package.json +7 -7
- package/partials/agents.md +3 -1
- package/src/commands/add.js +2 -5
- package/src/commands/template.js +35 -46
- package/src/framework-index.json +23 -98
- package/src/index.js +84 -86
- package/src/templates/fetchers/release.js +16 -62
- package/src/templates/index.js +15 -32
- package/src/templates/resolver.js +46 -73
- package/src/templates/validator.js +15 -187
- package/src/utils/exit.js +30 -0
- package/src/utils/scaffold.js +3 -20
- package/src/utils/update-check.js +73 -38
- package/src/versions.js +4 -1
|
@@ -2,93 +2,48 @@
|
|
|
2
2
|
* Template resolver - parses template identifiers and determines source type
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/scaffold.js
CHANGED
|
@@ -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
|
|
225
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
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
|
|
94
|
+
printNotification(currentVersion, state.latestVersion)
|
|
113
95
|
return true
|
|
114
96
|
}
|
|
115
97
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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')
|