uniweb 0.12.20 → 0.12.22

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.
@@ -166,6 +166,35 @@ async function processFile(sourcePath, targetPath, data, options = {}) {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Rename a leading single underscore to a dot: `_gitignore` → `.gitignore`,
171
+ * `_vscode` → `.vscode`.
172
+ *
173
+ * Dotfiles live in the template source under a `_`-prefixed name for two
174
+ * reasons: (1) so they aren't swept up by the template repo's own git ignore
175
+ * rules, and (2) — critically — so they survive `npm publish`. npm strips any
176
+ * file literally named `.gitignore` from the package tarball (it's treated as
177
+ * an ignore-source, not shippable content), so a template that needs to ship a
178
+ * `.gitignore` MUST store it as `_gitignore` and rename it at scaffold time.
179
+ * `.gitkeep` and other dotfiles ship fine; `.gitignore` is the trap.
180
+ *
181
+ * `__name` (double underscore) is left untouched, matching the prior
182
+ * directory-only behavior.
183
+ */
184
+ function dotfileRename(name) {
185
+ return name.startsWith('_') && !name.startsWith('__') ? `.${name.slice(1)}` : name
186
+ }
187
+
188
+ /**
189
+ * Compute a template file's scaffolded output name: strip the `.hbs`
190
+ * Handlebars suffix, then apply {@link dotfileRename}. So `_gitignore` →
191
+ * `.gitignore` and `_env.local.hbs` → `.env.local`.
192
+ */
193
+ function templateOutputName(sourceName) {
194
+ const base = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
195
+ return dotfileRename(base)
196
+ }
197
+
169
198
  /**
170
199
  * Copy a directory structure recursively, processing templates
171
200
  *
@@ -187,12 +216,8 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
187
216
 
188
217
  if (entry.isDirectory()) {
189
218
  const sourceFullPath = path.join(sourcePath, sourceName)
190
- // Rename _prefix directories to .prefix (e.g., _vscode → .vscode)
191
- // This allows dotfile directories to be committed without being gitignored
192
- const targetName = sourceName.startsWith('_') && !sourceName.startsWith('__')
193
- ? `.${sourceName.slice(1)}`
194
- : sourceName
195
- const targetFullPath = path.join(targetPath, targetName)
219
+ // Rename _prefix directories to .prefix (e.g., _vscode → .vscode).
220
+ const targetFullPath = path.join(targetPath, dotfileRename(sourceName))
196
221
 
197
222
  await copyTemplateDirectory(sourceFullPath, targetFullPath, data, { onWarning, onProgress, skip })
198
223
  } else {
@@ -201,17 +226,14 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
201
226
  continue
202
227
  }
203
228
 
204
- // Determine the output filename (strip .hbs extension) for skip check
205
- const outputName = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
206
- if (skip?.includes(outputName)) {
229
+ // Output name: strip `.hbs`, then rename a leading `_` to `.`
230
+ // (e.g. `_gitignore` `.gitignore`). Used for both the skip check and
231
+ // the written filename so they can't drift.
232
+ const targetName = templateOutputName(sourceName)
233
+ if (skip?.includes(targetName)) {
207
234
  continue
208
235
  }
209
236
 
210
- // Remove .hbs extension for target filename
211
- const targetName = sourceName.endsWith('.hbs')
212
- ? sourceName.slice(0, -4)
213
- : sourceName
214
-
215
237
  const sourceFullPath = path.join(sourcePath, sourceName)
216
238
  const targetFullPath = path.join(targetPath, targetName)
217
239
 
@@ -228,7 +250,9 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
228
250
  * Enumerate the output paths a template directory would write, without
229
251
  * touching disk. Mirrors `copyTemplateDirectory`'s naming rules:
230
252
  * - `.hbs` extension is stripped
231
- * - `_dir` is renamed to `.dir` (but `__dir` is preserved as `_dir` would be)
253
+ * - a leading `_` is renamed to `.` for both files and directories
254
+ * (e.g. `_gitignore` → `.gitignore`, `_vscode` → `.vscode`); `__name`
255
+ * is preserved
232
256
  * - `template.json` is excluded
233
257
  * - `skip` filenames are excluded by their post-rename name
234
258
  *
@@ -253,9 +277,7 @@ async function enumerateInto(sourcePath, relPath, outputs, skip) {
253
277
  for (const entry of entries) {
254
278
  const sourceName = entry.name
255
279
  if (entry.isDirectory()) {
256
- const targetName = sourceName.startsWith('_') && !sourceName.startsWith('__')
257
- ? `.${sourceName.slice(1)}`
258
- : sourceName
280
+ const targetName = dotfileRename(sourceName)
259
281
  await enumerateInto(
260
282
  path.join(sourcePath, sourceName),
261
283
  relPath ? path.join(relPath, targetName) : targetName,
@@ -264,7 +286,7 @@ async function enumerateInto(sourcePath, relPath, outputs, skip) {
264
286
  )
265
287
  } else {
266
288
  if (sourceName === 'template.json') continue
267
- const outputName = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
289
+ const outputName = templateOutputName(sourceName)
268
290
  if (skip.includes(outputName)) continue
269
291
  outputs.push(relPath ? path.join(relPath, outputName) : outputName)
270
292
  }
@@ -20,6 +20,7 @@ import { join } from 'node:path'
20
20
  import { homedir } from 'node:os'
21
21
  import yaml from 'js-yaml'
22
22
  import { filterCmd } from './pm.js'
23
+ import { writeJsonPreservingStyleAsync } from './json-file.js'
23
24
 
24
25
  // ── Platform URLs ──────────────────────────────────────────────
25
26
 
@@ -82,6 +83,27 @@ export function getRegistryUrl() {
82
83
  || PRODUCTION_REGISTRY_URL
83
84
  }
84
85
 
86
+ /**
87
+ * Get the new registry backend's API base origin — DISTINCT from the
88
+ * legacy PHP getBackendUrl(). `register` POSTs to {origin}/dev/registry/register
89
+ * and the new-backend `login` to {origin}/dev/auth/login.
90
+ *
91
+ * Priority: UNIWEB_REGISTER_URL's origin (the env users already set for
92
+ * register) > ~/.uniweb/config.json registryApiUrl > local default.
93
+ * @returns {string}
94
+ */
95
+ export function getRegistryApiBaseUrl() {
96
+ const fromEnv = process.env.UNIWEB_REGISTER_URL
97
+ if (fromEnv) {
98
+ try { return new URL(fromEnv).origin } catch { /* fall through */ }
99
+ }
100
+ const fromCfg = readCliConfig().registryApiUrl
101
+ if (fromCfg) {
102
+ try { return new URL(fromCfg).origin } catch { return fromCfg }
103
+ }
104
+ return 'http://localhost:8080'
105
+ }
106
+
85
107
  /**
86
108
  * Read workspace package globs.
87
109
  * Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
@@ -154,13 +176,19 @@ export async function readRootPackageJson(rootDir) {
154
176
  }
155
177
 
156
178
  /**
157
- * Write root package.json (2-space indent)
179
+ * Write root package.json, preserving the file's existing indentation
180
+ * (newly scaffolded workspaces use 2-space; we don't reflow whatever's
181
+ * already there).
158
182
  * @param {string} rootDir - Workspace root directory
159
183
  * @param {Object} pkg - Package.json object
160
184
  */
161
185
  export async function writeRootPackageJson(rootDir, pkg) {
162
186
  const pkgPath = join(rootDir, 'package.json')
163
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
187
+ if (existsSync(pkgPath)) {
188
+ await writeJsonPreservingStyleAsync(pkgPath, pkg)
189
+ } else {
190
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
191
+ }
164
192
  }
165
193
 
166
194
  /**
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workspace `@uniweb/*` dependency survey.
3
+ *
4
+ * Compares the `@uniweb/*` + `uniweb` versions *declared* in every
5
+ * package.json across a workspace against the running CLI's bundled
6
+ * version matrix (`getResolvedVersions`). Shared by `uniweb update`
7
+ * (which fixes the drift) and `uniweb doctor` (which only reports it) so
8
+ * the two never disagree about what "out of date" means.
9
+ *
10
+ * Comparison is on declared specs, not installed (node_modules) versions
11
+ * — that's what's committed and what `git diff` will show after a fix.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { getResolvedVersions } from '../versions.js'
17
+ import { getWorkspacePackages } from './workspace.js'
18
+
19
+ /**
20
+ * Strip a leading semver range operator (^, ~, >=, <, …) so two specs can
21
+ * be compared by their underlying version. Range expressions like
22
+ * ">=0.5 <0.7" aren't fully parsed — the first version-shaped token wins.
23
+ * Sufficient for `@uniweb/*` deps, which use `^x.y.z` / `x.y.z`.
24
+ * @param {string} spec
25
+ * @returns {string}
26
+ */
27
+ export function stripVersionRange(spec) {
28
+ return (spec || '').replace(/^[\^~>=<\s]+/, '').trim().split(/\s+/)[0] || ''
29
+ }
30
+
31
+ /**
32
+ * Compare two version specs (range prefix tolerated). Returns 1 / -1 / 0.
33
+ * @param {string} a
34
+ * @param {string} b
35
+ * @returns {number}
36
+ */
37
+ export function compareSemver(a, b) {
38
+ const pa = stripVersionRange(a).split('.').map(Number)
39
+ const pb = stripVersionRange(b).split('.').map(Number)
40
+ for (let i = 0; i < 3; i++) {
41
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
42
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
43
+ }
44
+ return 0
45
+ }
46
+
47
+ /**
48
+ * @typedef {object} DepRow
49
+ * @property {string} relDir Workspace-relative dir, or '(root)'.
50
+ * @property {string} section 'dependencies' | 'devDependencies' | 'peerDependencies'
51
+ * @property {string} name Package name (e.g. '@uniweb/core' or 'uniweb').
52
+ * @property {string} current The spec declared in package.json.
53
+ * @property {string} target The spec the running CLI's matrix wants.
54
+ * @property {'aligned'|'behind'|'ahead'} status current vs target.
55
+ */
56
+
57
+ /**
58
+ * Survey a workspace's declared `@uniweb/*` + `uniweb` deps against the
59
+ * running CLI's bundled matrix.
60
+ *
61
+ * @param {string} workspaceDir Absolute path to the workspace root.
62
+ * @returns {Promise<{ targets: Record<string,string>, rows: DepRow[], anyDrift: boolean, anyAhead: boolean }>}
63
+ * `anyDrift` — at least one dep lags the matrix. `anyAhead` — at least
64
+ * one dep is newer than the matrix.
65
+ */
66
+ export async function surveyWorkspaceDeps(workspaceDir) {
67
+ const targets = getResolvedVersions()
68
+ const packages = await getWorkspacePackages(workspaceDir)
69
+ const dirs = ['', ...packages]
70
+ const rows = []
71
+ let anyDrift = false
72
+ let anyAhead = false
73
+
74
+ for (const relDir of dirs) {
75
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
76
+ const pkgPath = join(pkgDir, 'package.json')
77
+ if (!existsSync(pkgPath)) continue
78
+ let pkg
79
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { continue }
80
+
81
+ for (const sectionName of ['dependencies', 'devDependencies', 'peerDependencies']) {
82
+ const section = pkg[sectionName]
83
+ if (!section) continue
84
+ for (const [name, current] of Object.entries(section)) {
85
+ if (!(name.startsWith('@uniweb/') || name === 'uniweb')) continue
86
+ const target = targets[name]
87
+ if (!target) continue
88
+ const cmp = compareSemver(target, current)
89
+ let status
90
+ if (cmp > 0) { status = 'behind'; anyDrift = true }
91
+ else if (cmp < 0) { status = 'ahead'; anyAhead = true }
92
+ else { status = 'aligned' }
93
+ rows.push({ relDir: relDir || '(root)', section: sectionName, name, current, target, status })
94
+ }
95
+ }
96
+ }
97
+
98
+ return { targets, rows, anyDrift, anyAhead }
99
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Style-preserving JSON file writes.
3
+ *
4
+ * The CLI rewrites `package.json` in several places (`update`, `doctor`,
5
+ * `rename`, `add`). A naive `JSON.stringify(obj, null, 2)` reflows the
6
+ * *entire* file whenever the project happened to use tabs or 4-space
7
+ * indentation — turning a one-key version bump into a hundred-line diff
8
+ * (and a needless merge-conflict surface). `framework/CLAUDE.md` calls
9
+ * this out as an anti-pattern for human commits; the tooling shouldn't do
10
+ * it either. These helpers detect the file's existing indentation and
11
+ * trailing-newline convention and preserve both.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from 'node:fs'
15
+ import { readFile, writeFile } from 'node:fs/promises'
16
+
17
+ /**
18
+ * Detect the indentation unit a JSON source string uses.
19
+ * @param {string} src
20
+ * @returns {number|string} A space count, or '\t' for tab-indented files.
21
+ * Defaults to 2 when the file has no indented lines (e.g. `{}`).
22
+ */
23
+ export function detectJsonIndent(src) {
24
+ const m = src.match(/\n([ \t]+)\S/)
25
+ if (!m) return 2
26
+ const lead = m[1]
27
+ if (lead.includes('\t')) return '\t'
28
+ return lead.length
29
+ }
30
+
31
+ /**
32
+ * Serialize `obj` to JSON using the indentation and trailing-newline
33
+ * convention of `originalSrc` (or of the file currently at `filePath`).
34
+ * @param {object} obj
35
+ * @param {string} originalSrc - The file's current text.
36
+ * @returns {string}
37
+ */
38
+ export function stringifyJsonLike(obj, originalSrc) {
39
+ const indent = detectJsonIndent(originalSrc)
40
+ const body = JSON.stringify(obj, null, indent)
41
+ return originalSrc.endsWith('\n') ? body + '\n' : body
42
+ }
43
+
44
+ /**
45
+ * Write `obj` to `filePath` as JSON, preserving the file's existing
46
+ * indentation and trailing-newline convention. Pass `originalSrc` when
47
+ * the caller already has the file contents in hand (avoids a re-read);
48
+ * otherwise the file is read to sniff its style.
49
+ * @param {string} filePath
50
+ * @param {object} obj
51
+ * @param {string|null} [originalSrc]
52
+ */
53
+ export function writeJsonPreservingStyle(filePath, obj, originalSrc = null) {
54
+ const src = originalSrc ?? readFileSync(filePath, 'utf8')
55
+ writeFileSync(filePath, stringifyJsonLike(obj, src))
56
+ }
57
+
58
+ /**
59
+ * Async counterpart to {@link writeJsonPreservingStyle}, for the CLI's
60
+ * many `node:fs/promises`-based call sites.
61
+ * @param {string} filePath
62
+ * @param {object} obj
63
+ * @param {string|null} [originalSrc]
64
+ */
65
+ export async function writeJsonPreservingStyleAsync(filePath, obj, originalSrc = null) {
66
+ const src = originalSrc ?? await readFile(filePath, 'utf8')
67
+ await writeFile(filePath, stringifyJsonLike(obj, src))
68
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Package placement resolution — shared by `add` and `clone`.
3
+ *
4
+ * Kept dependency-free (no `@uniweb/build`, no fs) on purpose: `clone` imports
5
+ * it and must run from a global install before any project — anything that
6
+ * statically pulls in `@uniweb/build` would crash `npx uniweb@latest clone`
7
+ * (the same reason `utils/workspace.js` loads the classifier lazily).
8
+ */
9
+
10
+ /** Foundation placement defaults (folder `src/`, package `src`). */
11
+ export const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
12
+
13
+ /** Site placement defaults (folder `site/`, package `site`). */
14
+ export const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
15
+
16
+ /**
17
+ * Resolve where a foundation or site should be placed, given the user's input.
18
+ *
19
+ * The rule: **the user names a folder, and we create exactly that folder.**
20
+ * No silent nesting under `foundations/` / `sites/`, no inferring layout from
21
+ * pre-existing globs. The framework doesn't require any particular folder
22
+ * structure (the build classifies packages by their contents, not their
23
+ * location), so the CLI shouldn't impose one.
24
+ *
25
+ * Resolution priority (foundation example, same shape for site):
26
+ *
27
+ * 1. `--path <dir>` → explicit folder. Name is the path's
28
+ * last segment (used as the package
29
+ * name unless `name` was also given).
30
+ * 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
31
+ * Folder = the path, package name =
32
+ * the last segment.
33
+ * 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
34
+ * 4. `--project <project>` → folder = `<project>/<defaultSub>` and
35
+ * package name = `<project>-<defaultSub>`
36
+ * (the co-located convention; only this
37
+ * one uses the `-src` / `-site` suffix).
38
+ * 5. (no input) → folder = `<defaultDir>/`, package name
39
+ * = `<defaultPkg>` (`src/` + `src`
40
+ * for foundations; `site/` + `site` for
41
+ * sites).
42
+ *
43
+ * @param {string} rootDir
44
+ * @param {string|null} name - Either a bare name or a path-with-slash.
45
+ * @param {{ path?: string, project?: string }} opts
46
+ * @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
47
+ * @returns {{ relativePath: string, packageName: string }}
48
+ */
49
+ export function resolvePlacement(rootDir, name, opts, kind) {
50
+ // 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
51
+ // name was given, or `<path>` itself if not (the path's last segment
52
+ // is then taken as the package name).
53
+ if (opts.path) {
54
+ const parent = opts.path.replace(/\/+$/, '')
55
+ if (name) {
56
+ const last = name.split('/').filter(Boolean).pop()
57
+ return {
58
+ relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
59
+ packageName: last,
60
+ }
61
+ }
62
+ const lastSegment = parent.split('/').filter(Boolean).pop() || parent
63
+ return {
64
+ relativePath: parent,
65
+ packageName: lastSegment,
66
+ }
67
+ }
68
+
69
+ // 2. name contains a slash → treat as a path.
70
+ if (name && name.includes('/')) {
71
+ const relativePath = name.replace(/\/+$/, '')
72
+ const lastSegment = relativePath.split('/').filter(Boolean).pop()
73
+ return {
74
+ relativePath,
75
+ packageName: lastSegment,
76
+ }
77
+ }
78
+
79
+ // 3. Bare name.
80
+ if (name) {
81
+ return {
82
+ relativePath: name,
83
+ packageName: name,
84
+ }
85
+ }
86
+
87
+ // 4. --project (co-located convention with -src / -site suffix).
88
+ if (opts.project) {
89
+ return {
90
+ relativePath: `${opts.project}/${kind.projectSub}`,
91
+ packageName: `${opts.project}-${kind.projectSub}`,
92
+ }
93
+ }
94
+
95
+ // 5. Default placement.
96
+ return {
97
+ relativePath: kind.defaultDir,
98
+ packageName: kind.defaultPkg,
99
+ }
100
+ }
package/src/utils/pm.js CHANGED
@@ -40,6 +40,35 @@ export function detectWorkspacePm(workspaceRoot) {
40
40
  return null
41
41
  }
42
42
 
43
+ /**
44
+ * Detect which package manager owns a *globally installed* `uniweb` CLI
45
+ * binary, by inspecting its install path (`process.argv[1]`). pnpm and
46
+ * yarn keep global packages under recognizable directory segments;
47
+ * everything else is assumed to be npm. Only meaningful when the CLI is
48
+ * actually a global install (see index.js::isGlobalInstall) — a
49
+ * project-local or npx-launched copy is updated differently.
50
+ *
51
+ * @returns {'pnpm' | 'yarn' | 'npm'}
52
+ */
53
+ export function detectGlobalCliPm() {
54
+ const path = (process.argv[1] || '').toLowerCase().replace(/\\/g, '/')
55
+ if (path.includes('/pnpm/')) return 'pnpm'
56
+ if (path.includes('/yarn/')) return 'yarn'
57
+ return 'npm'
58
+ }
59
+
60
+ /**
61
+ * The command to (re)install the latest `uniweb` CLI globally with a
62
+ * given package manager.
63
+ * @param {'pnpm' | 'yarn' | 'npm'} pm
64
+ * @returns {string}
65
+ */
66
+ export function globalCliUpdateCmd(pm) {
67
+ if (pm === 'pnpm') return 'pnpm add -g uniweb@latest'
68
+ if (pm === 'yarn') return 'yarn global add uniweb@latest'
69
+ return 'npm i -g uniweb@latest'
70
+ }
71
+
43
72
  /**
44
73
  * Generate a workspace-filtered command.
45
74
  * pnpm: "pnpm --filter site dev"