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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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
+ }
@@ -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
+ }