uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Detection Utilities
|
|
3
|
+
*
|
|
4
|
+
* Detects workspace structure (pnpm-workspace.yaml or package.json workspaces)
|
|
5
|
+
* and classifies packages as foundations or sites.
|
|
6
|
+
* Used by commands to auto-detect targets when run from workspace root.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { readFile } from 'node:fs/promises'
|
|
11
|
+
import { resolve, dirname, join } from 'node:path'
|
|
12
|
+
import yaml from 'js-yaml'
|
|
13
|
+
|
|
14
|
+
// `classifyPackage` from @uniweb/build is loaded lazily — this module
|
|
15
|
+
// is statically imported by index.js (for `findWorkspaceRoot`), and a
|
|
16
|
+
// top-level @uniweb/build import would crash `npx uniweb@latest create`
|
|
17
|
+
// before any command runs (the npx scratch dir has only the CLI's
|
|
18
|
+
// declared deps; @uniweb/build comes from a project's node_modules).
|
|
19
|
+
let _classifyPackageSync = null
|
|
20
|
+
async function getClassifier() {
|
|
21
|
+
if (!_classifyPackageSync) {
|
|
22
|
+
const mod = await import('@uniweb/build')
|
|
23
|
+
_classifyPackageSync = mod.classifyPackage
|
|
24
|
+
}
|
|
25
|
+
return _classifyPackageSync
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a directory is a workspace root.
|
|
30
|
+
* Recognizes pnpm-workspace.yaml or package.json with workspaces field.
|
|
31
|
+
* @param {string} dir - Directory to check
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function hasWorkspaceConfig(dir) {
|
|
35
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true
|
|
36
|
+
const pkgPath = join(dir, 'package.json')
|
|
37
|
+
if (existsSync(pkgPath)) {
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
40
|
+
if (Array.isArray(pkg.workspaces)) return true
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find workspace root by looking for pnpm-workspace.yaml or package.json workspaces
|
|
50
|
+
* @param {string} startDir - Directory to start searching from
|
|
51
|
+
* @returns {string|null} - Workspace root path or null
|
|
52
|
+
*/
|
|
53
|
+
export function findWorkspaceRoot(startDir = process.cwd()) {
|
|
54
|
+
let dir = startDir
|
|
55
|
+
while (dir !== dirname(dir)) {
|
|
56
|
+
if (hasWorkspaceConfig(dir)) {
|
|
57
|
+
return dir
|
|
58
|
+
}
|
|
59
|
+
dir = dirname(dir)
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve workspace package patterns to actual directories
|
|
66
|
+
* Handles patterns like: "foundation", "site", "foundations/*", "sites/*"
|
|
67
|
+
*
|
|
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
|
+
* @param {string} workspaceRoot
|
|
111
|
+
* @returns {Promise<string[]>} - Array of package directories (relative paths)
|
|
112
|
+
*/
|
|
113
|
+
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 []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Classify a package as foundation, site, or unknown.
|
|
142
|
+
*
|
|
143
|
+
* Re-exports the canonical (sync) classifier from @uniweb/build, kept
|
|
144
|
+
* async-shaped here so existing call sites continue to work without an
|
|
145
|
+
* await-removal sweep. New code should import directly from @uniweb/build.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} packagePath - Full path to package directory
|
|
148
|
+
* @returns {Promise<'foundation'|'site'|null>}
|
|
149
|
+
*/
|
|
150
|
+
export async function classifyPackage(packagePath) {
|
|
151
|
+
const classify = await getClassifier()
|
|
152
|
+
return classify(packagePath)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Find all foundations in workspace
|
|
157
|
+
* @param {string} workspaceRoot
|
|
158
|
+
* @returns {Promise<string[]>} - Array of foundation paths (relative to workspace root)
|
|
159
|
+
*/
|
|
160
|
+
export async function findFoundations(workspaceRoot) {
|
|
161
|
+
const [packages, classify] = await Promise.all([
|
|
162
|
+
getWorkspacePackages(workspaceRoot),
|
|
163
|
+
getClassifier()
|
|
164
|
+
])
|
|
165
|
+
return packages.filter(
|
|
166
|
+
(pkg) => classify(join(workspaceRoot, pkg)) === 'foundation'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Find all sites in workspace
|
|
172
|
+
* @param {string} workspaceRoot
|
|
173
|
+
* @returns {Promise<string[]>} - Array of site paths (relative to workspace root)
|
|
174
|
+
*/
|
|
175
|
+
export async function findSites(workspaceRoot) {
|
|
176
|
+
const [packages, classify] = await Promise.all([
|
|
177
|
+
getWorkspacePackages(workspaceRoot),
|
|
178
|
+
getClassifier()
|
|
179
|
+
])
|
|
180
|
+
return packages.filter((pkg) => classify(join(workspaceRoot, pkg)) === 'site')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if current directory is the workspace root
|
|
185
|
+
* @param {string} dir - Directory to check
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
export function isWorkspaceRoot(dir = process.cwd()) {
|
|
189
|
+
return hasWorkspaceConfig(dir)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Interactive prompt to select from multiple options.
|
|
194
|
+
*
|
|
195
|
+
* Accepts plain strings (title === value) or `{ title, value, description }`
|
|
196
|
+
* objects. `prompts` reveals a choice's description only while that row is
|
|
197
|
+
* highlighted, so put the always-visible qualifier in `title` and the
|
|
198
|
+
* fuller explanation in `description`.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} message - Prompt message
|
|
201
|
+
* @param {Array<string|{title: string, value?: any, description?: string}>} choices
|
|
202
|
+
* @returns {Promise<any|null>} - Selected value, or null if cancelled
|
|
203
|
+
*/
|
|
204
|
+
export async function promptSelect(message, choices) {
|
|
205
|
+
const prompts = (await import('prompts')).default
|
|
206
|
+
|
|
207
|
+
const response = await prompts({
|
|
208
|
+
type: 'select',
|
|
209
|
+
name: 'value',
|
|
210
|
+
message,
|
|
211
|
+
choices: choices.map((c) =>
|
|
212
|
+
typeof c === 'string'
|
|
213
|
+
? { title: c, value: c }
|
|
214
|
+
: {
|
|
215
|
+
title: c.title,
|
|
216
|
+
value: c.value !== undefined ? c.value : c.title,
|
|
217
|
+
description: c.description
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return response.value ?? null
|
|
223
|
+
}
|
package/src/versions.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version resolution utility
|
|
3
|
+
*
|
|
4
|
+
* Produces npm-compatible version specs for every `@uniweb/*` package the
|
|
5
|
+
* scaffolder might reference when materializing a new project. The caller
|
|
6
|
+
* (templates/processor.js via the `{{version}}` Handlebars helper) never
|
|
7
|
+
* needs to know whether the CLI is running locally from a pnpm workspace
|
|
8
|
+
* or as an npm-installed binary — both code paths feed through here.
|
|
9
|
+
*
|
|
10
|
+
* ## How versions are resolved
|
|
11
|
+
*
|
|
12
|
+
* 1. Start from the CLI's own `package.json` dependencies. A published CLI
|
|
13
|
+
* carries concrete ranges like `~0.9.1` there — pnpm rewrites each
|
|
14
|
+
* `workspace:~` spec at publish.
|
|
15
|
+
* 2. For any dep that is still a `workspace:` spec (the CLI is running
|
|
16
|
+
* *from* a workspace checkout — local dev, sandboxes, tests), read the
|
|
17
|
+
* `package.json` of the sibling package under `framework/<name>/` and
|
|
18
|
+
* use `~<that version>`: the range a published CLI would carry.
|
|
19
|
+
* 3. Scan `framework/*` for every other `@uniweb/*` package (press, loom,
|
|
20
|
+
* scholar, etc.), so a new package reaches the scaffolder automatically.
|
|
21
|
+
* 4. A published CLI has no `framework/` beside it: it reads every other
|
|
22
|
+
* package's version from its bundled `framework-index.json`, refreshed
|
|
23
|
+
* when the CLI is published.
|
|
24
|
+
*
|
|
25
|
+
* ⭐ EVERY RANGE IS A TILDE. A breaking change ships as a minor bump, in 1.x as in
|
|
26
|
+
* 0.x *[ruled 2026-10-11]*, and `~1.4.0` admits 1.4.x alone, where `^1.4.0` would
|
|
27
|
+
* admit 1.5.0. So a project receives fixes and features (patches) by itself, and a
|
|
28
|
+
* breaking release through `uniweb update`, which says so. Below 1.0 a caret meant
|
|
29
|
+
* the same, which is why the table wrote one until then.
|
|
30
|
+
*
|
|
31
|
+
* ⭐ A version is not a reason to depend on a package. Steps 3 and 4 name
|
|
32
|
+
* every `@uniweb/*` package, so the CLI's `dependencies` list only what its
|
|
33
|
+
* code loads — each one is installed with every `npx uniweb`.
|
|
34
|
+
*
|
|
35
|
+
* The previous implementation shipped a hardcoded fallback table that
|
|
36
|
+
* nothing kept in sync, so every scaffold silently pinned years-old
|
|
37
|
+
* versions. That table is gone. A package none of these steps resolves gets
|
|
38
|
+
* the `{{version}}` helper's `^0.1.0` (templates/processor.js).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
42
|
+
import { dirname, join } from 'node:path'
|
|
43
|
+
import { fileURLToPath } from 'node:url'
|
|
44
|
+
|
|
45
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
46
|
+
|
|
47
|
+
// Cache for resolved versions
|
|
48
|
+
let resolvedVersions = null
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The React major the framework standardizes on.
|
|
52
|
+
*
|
|
53
|
+
* React 19 changed the element marker symbol, so mixing React 18 and 19
|
|
54
|
+
* across the foundation ↔ runtime SSR boundary breaks rendering. The whole
|
|
55
|
+
* toolchain therefore pins a single major. This is the one place that value
|
|
56
|
+
* lives: scaffolded `package.json` files reference it through the
|
|
57
|
+
* `{{version "react"}}` / `{{version "react-dom"}}` template helpers instead
|
|
58
|
+
* of hardcoding a range.
|
|
59
|
+
*/
|
|
60
|
+
export const REACT_VERSION = '^19.0.0'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Default pnpm major for generated CI, used only when the project does not
|
|
64
|
+
* say which pnpm it uses (see `resolveCiPnpmVersion`).
|
|
65
|
+
*
|
|
66
|
+
* **Tracks the current stable major.** `pnpm@latest` is 11.x, so a developer
|
|
67
|
+
* running `npm i -g pnpm` today gets 11 — and CI should run what they run.
|
|
68
|
+
* Both of pnpm 11's install-time policies are handled:
|
|
69
|
+
*
|
|
70
|
+
* - Build-script approval. pnpm 11 renamed `onlyBuiltDependencies` (list)
|
|
71
|
+
* to `allowBuilds` (map) and ignores the old name, so an unprepared
|
|
72
|
+
* project fails with `ERR_PNPM_IGNORED_BUILDS`. The workspace template
|
|
73
|
+
* emits both spellings, so scaffolds install on either major.
|
|
74
|
+
* - Minimum release age. pnpm 11 refuses dependencies published in the
|
|
75
|
+
* last 24 hours. Installing locally with pnpm 11 auto-collects the
|
|
76
|
+
* needed `minimumReleaseAgeExclude` entries into `pnpm-workspace.yaml`,
|
|
77
|
+
* and CI then honours them — verified end to end.
|
|
78
|
+
*
|
|
79
|
+
* **The one combination that breaks is installing locally with pnpm 10 while
|
|
80
|
+
* CI runs 11**: pnpm 10 never writes those exclude entries, so CI rejects any
|
|
81
|
+
* dependency in the lockfile published within the last day — including
|
|
82
|
+
* unrelated transitive ones. A project still on pnpm 10 should say so with
|
|
83
|
+
* `"packageManager": "pnpm@10.x.y"`, which `resolveCiPnpmVersion` honours and
|
|
84
|
+
* which pins CI to match. `uniweb add ci` prints the resolved toolchain so
|
|
85
|
+
* that choice is visible rather than silent.
|
|
86
|
+
*
|
|
87
|
+
* A bare major installs the latest patch of that major at CI run time. Before
|
|
88
|
+
* bumping this, check the new major's release notes for install-time policy
|
|
89
|
+
* changes, and update `PNPM_MIN_NODE` in the same commit.
|
|
90
|
+
*/
|
|
91
|
+
export const PNPM_VERSION = '11'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Minimum Node major each supported pnpm major will run on, from that
|
|
95
|
+
* release's own `engines.node`.
|
|
96
|
+
*
|
|
97
|
+
* pnpm 11 declares `>=22.13` and imports `node:sqlite`, which does not exist
|
|
98
|
+
* before Node 22 — pairing it with Node 20 fails at `pnpm install` with
|
|
99
|
+
* `ERR_UNKNOWN_BUILTIN_MODULE` before the build is attempted. That was the
|
|
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`.
|
|
102
|
+
*
|
|
103
|
+
* Verify with `npm view pnpm@<major> engines` when adding an entry.
|
|
104
|
+
*/
|
|
105
|
+
export const PNPM_MIN_NODE = { 10: 18, 11: 22 }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the pnpm major a generated CI workflow should install.
|
|
109
|
+
*
|
|
110
|
+
* Observed, not assumed, in this order:
|
|
111
|
+
*
|
|
112
|
+
* 1. `packageManager` in package.json — the project stating explicitly
|
|
113
|
+
* which pnpm it is developed and locked against. Honour it.
|
|
114
|
+
* 2. What actually installed the project, from
|
|
115
|
+
* `node_modules/.modules.yaml` (`detectInstalledPnpmVersion`). A record
|
|
116
|
+
* of fact: the developer running `add ci` is the developer who ran
|
|
117
|
+
* `pnpm install`, and `add ci` refuses to run before install, so this
|
|
118
|
+
* is normally present.
|
|
119
|
+
* 3. `PNPM_VERSION` — a last resort that should rarely apply.
|
|
120
|
+
*
|
|
121
|
+
* There is deliberately no inference from *how the CLI was invoked*. The
|
|
122
|
+
* documented way to scaffold is `npm create uniweb`, commonly followed by
|
|
123
|
+
* `pnpm install`, so the invoking manager says nothing about the project's.
|
|
124
|
+
* Writing a `packageManager` field at scaffold time was considered and
|
|
125
|
+
* rejected for the same reason — it would guess intent and, being enforced
|
|
126
|
+
* by corepack, would be a hard pin rather than a hint.
|
|
127
|
+
*
|
|
128
|
+
* `pnpm-lock.yaml`'s `lockfileVersion` is NOT usable here: pnpm 10 and 11
|
|
129
|
+
* both write `9.0`, so it cannot distinguish them. Checked, not assumed.
|
|
130
|
+
*
|
|
131
|
+
* @param {object|null} rootPkg — the workspace root's parsed package.json.
|
|
132
|
+
* @param {string|null} [installedMajor] — from `detectInstalledPnpmVersion`.
|
|
133
|
+
* @returns {string} pnpm major, as a string for YAML interpolation.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveCiPnpmVersion(rootPkg, installedMajor = null) {
|
|
136
|
+
const declared = rootPkg?.packageManager
|
|
137
|
+
const match =
|
|
138
|
+
typeof declared === 'string' ? declared.match(/^pnpm@(\d+)/) : null
|
|
139
|
+
if (match) return match[1]
|
|
140
|
+
if (installedMajor) return String(installedMajor)
|
|
141
|
+
return PNPM_VERSION
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the Node major a generated CI workflow should install.
|
|
146
|
+
*
|
|
147
|
+
* Respects the project's own floor (`engines.node`) but never drops below
|
|
148
|
+
* what the pinned package manager needs. The project's declared minimum is a
|
|
149
|
+
* *lower* bound on what its code needs, not a ceiling — running CI on a newer
|
|
150
|
+
* Node is fine; running it on one the package manager refuses to start on is
|
|
151
|
+
* not.
|
|
152
|
+
*
|
|
153
|
+
* @param {string|null|undefined} enginesNode — the project's `engines.node`.
|
|
154
|
+
* @param {'pnpm'|'npm'|'yarn'} packageManager
|
|
155
|
+
* @param {string} [pnpmVersion] — the resolved pnpm major, when pnpm.
|
|
156
|
+
* @param {string} [fallback='20'] — used when engines.node is absent/unparseable.
|
|
157
|
+
* @returns {string} Node major, as a string for YAML interpolation.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveCiNodeVersion(
|
|
160
|
+
enginesNode,
|
|
161
|
+
packageManager,
|
|
162
|
+
pnpmVersion = PNPM_VERSION,
|
|
163
|
+
fallback = '20'
|
|
164
|
+
) {
|
|
165
|
+
const match = enginesNode ? String(enginesNode).match(/(\d+)/) : null
|
|
166
|
+
const declared = match ? Number(match[1]) : Number(fallback)
|
|
167
|
+
const floor =
|
|
168
|
+
packageManager === 'pnpm' ? (PNPM_MIN_NODE[pnpmVersion] ?? 0) : 0
|
|
169
|
+
return String(Math.max(declared, floor))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the CLI's own package.json
|
|
174
|
+
*/
|
|
175
|
+
function getCliPackageJson() {
|
|
176
|
+
const packagePath = join(__dirname, '..', 'package.json')
|
|
177
|
+
return JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get the CLI's own version
|
|
182
|
+
*
|
|
183
|
+
* @returns {string} CLI version (e.g., "0.8.32")
|
|
184
|
+
*/
|
|
185
|
+
export function getCliVersion() {
|
|
186
|
+
return getCliPackageJson().version
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Locate the framework/ directory on disk. When the CLI is running from
|
|
191
|
+
* the pnpm workspace, this resolves to `<workspace>/framework/`. When the
|
|
192
|
+
* CLI is installed from npm, this directory won't exist — callers must
|
|
193
|
+
* handle that (return null) so the function doesn't pretend to know more
|
|
194
|
+
* than it does.
|
|
195
|
+
*/
|
|
196
|
+
function getFrameworkRoot() {
|
|
197
|
+
const candidate = join(__dirname, '..', '..')
|
|
198
|
+
try {
|
|
199
|
+
if (statSync(candidate).isDirectory()) {
|
|
200
|
+
return candidate
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read the current on-disk version of a specific `@uniweb/*` package by
|
|
208
|
+
* looking up `framework/<last-segment>/package.json`, as a tilde range
|
|
209
|
+
* (e.g. `~0.7.11`) — or null if the package isn't on disk (the CLI is
|
|
210
|
+
* running from npm, not from a workspace checkout).
|
|
211
|
+
*
|
|
212
|
+
* ⭐ A tilde, because that is what a published CLI carries: pnpm rewrites
|
|
213
|
+
* its `workspace:~` deps to `~<version>` at publish. So `uniweb update` and
|
|
214
|
+
* the scaffolder write the same `package.json` whether the CLI ran from npm
|
|
215
|
+
* or from a workspace. ⛔ This returned the version exact until 2026-10-11:
|
|
216
|
+
* right while the deps were `workspace:*`, which pnpm publishes exact, and
|
|
217
|
+
* wrong once they became a range — a scaffold from a workspace checkout
|
|
218
|
+
* pinned `0.20.0` where a user's got `^0.20.0`.
|
|
219
|
+
*/
|
|
220
|
+
function readWorkspaceVersion(packageName) {
|
|
221
|
+
const root = getFrameworkRoot()
|
|
222
|
+
if (!root) return null
|
|
223
|
+
const shortName = packageName.startsWith('@uniweb/')
|
|
224
|
+
? packageName.slice('@uniweb/'.length)
|
|
225
|
+
: packageName
|
|
226
|
+
const pkgPath = join(root, shortName, 'package.json')
|
|
227
|
+
try {
|
|
228
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
229
|
+
if (pkg.name === packageName && pkg.version) {
|
|
230
|
+
return `~${pkg.version}`
|
|
231
|
+
}
|
|
232
|
+
} catch {}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load the vendored framework index file (see
|
|
238
|
+
* `./framework-index.json`). The index is a snapshot of the framework
|
|
239
|
+
* state taken at CLI publish time — every `@uniweb/*` package name,
|
|
240
|
+
* version, path, and inter-package dep edges, plus the template list.
|
|
241
|
+
*
|
|
242
|
+
* The snapshot is the only way a published CLI (running from
|
|
243
|
+
* `node_modules/uniweb/…`, with no workspace on disk) can resolve
|
|
244
|
+
* versions for packages it doesn't directly import (press, loom,
|
|
245
|
+
* scholar, etc.). Without it, Handlebars helpers like
|
|
246
|
+
* `{{version "@uniweb/press"}}` fall through to a bogus default.
|
|
247
|
+
*
|
|
248
|
+
* Returns null if the file doesn't exist, is unparseable, or has a
|
|
249
|
+
* schema version we don't understand. Callers treat null as "no
|
|
250
|
+
* snapshot available" and fall back to their next source.
|
|
251
|
+
*
|
|
252
|
+
* Also the official templates' one source (`templates/resolver.js`): their
|
|
253
|
+
* roster, and the `@uniweb/templates` release their content downloads from.
|
|
254
|
+
*/
|
|
255
|
+
export function loadFrameworkIndex() {
|
|
256
|
+
const indexPath = join(__dirname, 'framework-index.json')
|
|
257
|
+
try {
|
|
258
|
+
const raw = readFileSync(indexPath, 'utf8')
|
|
259
|
+
const parsed = JSON.parse(raw)
|
|
260
|
+
if (parsed && parsed.schemaVersion === 1 && parsed.packages) {
|
|
261
|
+
return parsed
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Pull `@uniweb/*` package versions out of the framework index's
|
|
269
|
+
* `packages` field and format them as tilde ranges. Used as the
|
|
270
|
+
* published-CLI fallback source after the CLI's own deps and the live
|
|
271
|
+
* workspace walk both come up empty.
|
|
272
|
+
*/
|
|
273
|
+
function readIndexPackages() {
|
|
274
|
+
const index = loadFrameworkIndex()
|
|
275
|
+
if (!index) return {}
|
|
276
|
+
const result = {}
|
|
277
|
+
for (const [name, entry] of Object.entries(index.packages)) {
|
|
278
|
+
if (entry && entry.version) {
|
|
279
|
+
result[name] = `~${entry.version}`
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Enumerate every `@uniweb/*` package under `framework/*` and return a
|
|
287
|
+
* map of `{ name: '^version' }`. Used to seed the resolved-versions
|
|
288
|
+
* cache so that packages not explicitly listed in the CLI's own deps
|
|
289
|
+
* (press, loom, scholar, schemas, etc.) still have a version available
|
|
290
|
+
* to templates that reference them.
|
|
291
|
+
*
|
|
292
|
+
* Works only in the monorepo. Published CLIs fall through to the
|
|
293
|
+
* framework-index snapshot via readIndexPackages() instead.
|
|
294
|
+
*/
|
|
295
|
+
function discoverWorkspacePackages() {
|
|
296
|
+
const root = getFrameworkRoot()
|
|
297
|
+
if (!root) return {}
|
|
298
|
+
const found = {}
|
|
299
|
+
let entries
|
|
300
|
+
try {
|
|
301
|
+
entries = readdirSync(root, { withFileTypes: true })
|
|
302
|
+
} catch {
|
|
303
|
+
return {}
|
|
304
|
+
}
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
if (!entry.isDirectory()) continue
|
|
307
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue
|
|
308
|
+
const pkgPath = join(root, entry.name, 'package.json')
|
|
309
|
+
try {
|
|
310
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
311
|
+
if (pkg.name && pkg.version && pkg.name.startsWith('@uniweb/')) {
|
|
312
|
+
found[pkg.name] = `~${pkg.version}`
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
315
|
+
}
|
|
316
|
+
return found
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve a single version spec to something npm can install. A concrete
|
|
321
|
+
* spec like `~0.9.0` passes through untouched; a `workspace:` spec becomes
|
|
322
|
+
* `~<on-disk version>` of the named package; anything still unresolved
|
|
323
|
+
* returns null so the caller can fall back.
|
|
324
|
+
*/
|
|
325
|
+
function resolveVersionSpec(spec, packageName) {
|
|
326
|
+
if (!spec) return null
|
|
327
|
+
if (spec.startsWith('workspace:')) {
|
|
328
|
+
return readWorkspaceVersion(packageName)
|
|
329
|
+
}
|
|
330
|
+
return spec
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get resolved versions for @uniweb/* packages.
|
|
335
|
+
*
|
|
336
|
+
* Priority (highest first):
|
|
337
|
+
*
|
|
338
|
+
* 1. A concrete version spec already in the CLI's own `package.json`
|
|
339
|
+
* (the state after an npm publish: pnpm rewrites `workspace:~` to
|
|
340
|
+
* `~<version>`), or a `workspace:` spec read as `~<on-disk version>`.
|
|
341
|
+
* 2. Live workspace walk — every `@uniweb/*` package found under
|
|
342
|
+
* `framework/*` at CLI invocation time. This is the path that
|
|
343
|
+
* matters for local dev: a freshly-added package becomes
|
|
344
|
+
* reachable from every locally-run CLI without republishing.
|
|
345
|
+
* 3. Framework index snapshot — `./framework-index.json`, written by
|
|
346
|
+
* the publish pipeline's pre-publish hook. This is the path that
|
|
347
|
+
* matters for published CLIs: the monorepo isn't on disk, so the
|
|
348
|
+
* snapshot is how the CLI knows about packages it doesn't import
|
|
349
|
+
* directly (press, loom, scholar, schemas, …).
|
|
350
|
+
*
|
|
351
|
+
* The return shape is stable across all paths: a map of package names
|
|
352
|
+
* to npm-compatible version specs, plus the CLI's own version under
|
|
353
|
+
* the key `uniweb`.
|
|
354
|
+
*
|
|
355
|
+
* @returns {Object} Map of package names to version specs
|
|
356
|
+
*/
|
|
357
|
+
export function getResolvedVersions() {
|
|
358
|
+
if (resolvedVersions) return resolvedVersions
|
|
359
|
+
|
|
360
|
+
const pkg = getCliPackageJson()
|
|
361
|
+
const deps = {
|
|
362
|
+
...pkg.dependencies,
|
|
363
|
+
...pkg.devDependencies,
|
|
364
|
+
...pkg.peerDependencies
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Seed from the CLI's own deps (the authoritative set when installed from npm).
|
|
368
|
+
const result = {}
|
|
369
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
370
|
+
if (!name.startsWith('@uniweb/')) continue
|
|
371
|
+
const resolved = resolveVersionSpec(spec, name)
|
|
372
|
+
if (resolved) result[name] = resolved
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Layer in the live workspace walk. Overrides nothing (dev versions
|
|
376
|
+
// are fresher than anything in the CLI's own deps), but fills in
|
|
377
|
+
// packages the CLI doesn't reference directly.
|
|
378
|
+
const discovered = discoverWorkspacePackages()
|
|
379
|
+
for (const [name, version] of Object.entries(discovered)) {
|
|
380
|
+
if (!result[name]) result[name] = version
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Final fallback: the vendored framework index snapshot. Only hits
|
|
384
|
+
// when the workspace walk came up empty for a package (published
|
|
385
|
+
// CLI running outside the monorepo). The snapshot is refreshed at
|
|
386
|
+
// publish time, so its view of the world is current as of the CLI's
|
|
387
|
+
// own publish.
|
|
388
|
+
const indexed = readIndexPackages()
|
|
389
|
+
for (const [name, version] of Object.entries(indexed)) {
|
|
390
|
+
if (!result[name]) result[name] = version
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// CLI itself. Tilde on the current version, like every other entry: a
|
|
394
|
+
// project takes the CLI's patches by itself, and a minor — breaking —
|
|
395
|
+
// through `uniweb update`.
|
|
396
|
+
result['uniweb'] = `~${pkg.version}`
|
|
397
|
+
|
|
398
|
+
resolvedVersions = result
|
|
399
|
+
return resolvedVersions
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Get a specific package version
|
|
404
|
+
*
|
|
405
|
+
* @param {string} packageName - Package name (e.g., "@uniweb/build")
|
|
406
|
+
* @returns {string} Version spec (e.g., "~0.1.4")
|
|
407
|
+
*/
|
|
408
|
+
export function getVersion(packageName) {
|
|
409
|
+
const versions = getResolvedVersions()
|
|
410
|
+
return versions[packageName] || null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get all versions as a flat object for template data
|
|
415
|
+
*
|
|
416
|
+
* @returns {Object} Versions keyed by simplified names
|
|
417
|
+
*/
|
|
418
|
+
export function getVersionsForTemplates() {
|
|
419
|
+
const versions = getResolvedVersions()
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
// Full package names
|
|
423
|
+
...versions,
|
|
424
|
+
|
|
425
|
+
// React pinned to the framework's official major (single source: REACT_VERSION above).
|
|
426
|
+
react: REACT_VERSION,
|
|
427
|
+
'react-dom': REACT_VERSION,
|
|
428
|
+
|
|
429
|
+
// Simplified names for templates (e.g., {{versions.build}})
|
|
430
|
+
build: versions['@uniweb/build'],
|
|
431
|
+
runtime: versions['@uniweb/runtime'],
|
|
432
|
+
core: versions['@uniweb/core'],
|
|
433
|
+
kit: versions['@uniweb/kit'],
|
|
434
|
+
templates: versions['@uniweb/templates'],
|
|
435
|
+
cli: versions['uniweb']
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Update @uniweb/* versions in a package.json object
|
|
441
|
+
*
|
|
442
|
+
* @param {Object} pkg - Package.json object
|
|
443
|
+
* @returns {Object} Updated package.json object
|
|
444
|
+
*/
|
|
445
|
+
export function updatePackageVersions(pkg) {
|
|
446
|
+
const versions = getResolvedVersions()
|
|
447
|
+
|
|
448
|
+
const updateDeps = (deps) => {
|
|
449
|
+
if (!deps) return deps
|
|
450
|
+
const updated = { ...deps }
|
|
451
|
+
for (const [name, version] of Object.entries(updated)) {
|
|
452
|
+
if (name.startsWith('@uniweb/') || name === 'uniweb') {
|
|
453
|
+
if (versions[name]) {
|
|
454
|
+
updated[name] = versions[name]
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return updated
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
...pkg,
|
|
463
|
+
dependencies: updateDeps(pkg.dependencies),
|
|
464
|
+
devDependencies: updateDeps(pkg.devDependencies),
|
|
465
|
+
peerDependencies: updateDeps(pkg.peerDependencies)
|
|
466
|
+
}
|
|
467
|
+
}
|