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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-Interactive Mode Detection
|
|
3
|
+
*
|
|
4
|
+
* Detects when the CLI is running without a TTY (AI agents, CI, piped input)
|
|
5
|
+
* and provides helpers for printing actionable error messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect if the CLI is running in non-interactive mode.
|
|
10
|
+
* True when --non-interactive flag, CI env var, or no TTY.
|
|
11
|
+
* @param {string[]} args - Command line arguments
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
export function isNonInteractive(args) {
|
|
15
|
+
if (args.includes('--non-interactive')) return true
|
|
16
|
+
if (process.env.CI) return true
|
|
17
|
+
if (!process.stdin.isTTY) return true
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
|
|
23
|
+
*
|
|
24
|
+
* Shared rather than per-command: every verb that can stop and ask needs one, and
|
|
25
|
+
* three hand-rolled copies would eventually disagree about what a bare Enter means
|
|
26
|
+
* — which is the answer the user gives most often and thinks about least.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ Callers must decide NOT to ask before calling: this always reads stdin, and a
|
|
29
|
+
* prompt in a non-interactive run hangs a pipeline. Gate on `isNonInteractive(args)`
|
|
30
|
+
* (and on any --yes/--force style skip the verb defines).
|
|
31
|
+
*/
|
|
32
|
+
export async function confirm(question, defaultYes = false) {
|
|
33
|
+
const { createInterface } = await import('node:readline/promises')
|
|
34
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
35
|
+
try {
|
|
36
|
+
const a = (
|
|
37
|
+
await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)
|
|
38
|
+
)
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
if (!a) return defaultYes
|
|
42
|
+
return a === 'y' || a === 'yes'
|
|
43
|
+
} finally {
|
|
44
|
+
rl.close()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ask the user to pick one of a short list, returning `fallback` on an empty answer
|
|
50
|
+
* or anything unparseable.
|
|
51
|
+
*
|
|
52
|
+
* ⛔ **Prompts on STDERR**, like everything else a human reads here — `stdout` is the
|
|
53
|
+
* CLI's data channel and a prompt written there corrupts any caller piping it.
|
|
54
|
+
*
|
|
55
|
+
* ⛔ Callers must decide NOT to ask before calling (see `confirm`): this reads stdin,
|
|
56
|
+
* and a prompt in a non-interactive run hangs a pipeline.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} question
|
|
59
|
+
* @param {string[]} options - shown in order, numbered from 1
|
|
60
|
+
* @param {string} fallback - returned for an empty or unparseable answer
|
|
61
|
+
* @returns {Promise<string>}
|
|
62
|
+
*/
|
|
63
|
+
export async function pickOne(question, options, fallback) {
|
|
64
|
+
if (!Array.isArray(options) || options.length === 0) return fallback
|
|
65
|
+
if (options.length === 1) return options[0]
|
|
66
|
+
const { createInterface } = await import('node:readline/promises')
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
68
|
+
try {
|
|
69
|
+
process.stderr.write(`${question}\n`)
|
|
70
|
+
options.forEach((o, i) => {
|
|
71
|
+
const mark = o === fallback ? ' \x1b[2m(default)\x1b[0m' : ''
|
|
72
|
+
process.stderr.write(` \x1b[1m${i + 1}\x1b[0m) ${o}${mark}\n`)
|
|
73
|
+
})
|
|
74
|
+
const a = (await rl.question(`Choose [1-${options.length}]: `)).trim()
|
|
75
|
+
if (!a) return fallback
|
|
76
|
+
const n = Number.parseInt(a, 10)
|
|
77
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]
|
|
78
|
+
// A pasted origin is a reasonable answer to a list of origins.
|
|
79
|
+
if (options.includes(a)) return a
|
|
80
|
+
return fallback
|
|
81
|
+
} finally {
|
|
82
|
+
rl.close()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the CLI invocation prefix to use in suggested commands.
|
|
88
|
+
* Mirrors however the user actually ran the CLI.
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function getCliPrefix() {
|
|
92
|
+
const ua = process.env.npm_config_user_agent || ''
|
|
93
|
+
if (ua.startsWith('pnpm/')) return 'pnpm uniweb'
|
|
94
|
+
if (ua.startsWith('npm/')) return 'npx uniweb'
|
|
95
|
+
return 'uniweb'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Strip --non-interactive from an args array so it doesn't interfere
|
|
100
|
+
* with positional argument parsing.
|
|
101
|
+
* @param {string[]} args
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
export function stripNonInteractiveFlag(args) {
|
|
105
|
+
return args.filter((a) => a !== '--non-interactive')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format a list of options with aligned descriptions for terminal output.
|
|
110
|
+
* @param {{ label: string, description: string }[]} options
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function formatOptions(options) {
|
|
114
|
+
const maxLen = Math.max(...options.map((o) => o.label.length))
|
|
115
|
+
return options
|
|
116
|
+
.map((o) => ` ${o.label.padEnd(maxLen + 3)}${o.description}`)
|
|
117
|
+
.join('\n')
|
|
118
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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(
|
|
66
|
+
filePath,
|
|
67
|
+
obj,
|
|
68
|
+
originalSrc = null
|
|
69
|
+
) {
|
|
70
|
+
const src = originalSrc ?? (await readFile(filePath, 'utf8'))
|
|
71
|
+
await writeFile(filePath, stringifyJsonLike(obj, src))
|
|
72
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package Name Validation
|
|
3
|
+
*
|
|
4
|
+
* Validates package names for the `add` command — rejects reserved names
|
|
5
|
+
* and detects collisions with existing workspace packages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { discoverFoundations, discoverSites } from './discover.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Names that must not be used as package names.
|
|
12
|
+
* - JS module keywords: default, undefined, null, true, false
|
|
13
|
+
* - Node/filesystem: node_modules, package
|
|
14
|
+
* - Common build-output directories: dist, build (would shadow `dist/` /
|
|
15
|
+
* `build/` references)
|
|
16
|
+
*
|
|
17
|
+
* Note: `src` is NOT reserved. A foundation in `src/` whose package name is
|
|
18
|
+
* also `src` is the default scaffold pattern — folder name and package name
|
|
19
|
+
* match. Multi-foundation co-located workspaces still use suffixes
|
|
20
|
+
* (`<project>-src`) because pnpm requires workspace-unique package names;
|
|
21
|
+
* that's a real constraint, not aesthetic.
|
|
22
|
+
*/
|
|
23
|
+
const RESERVED_NAMES = new Set([
|
|
24
|
+
'default',
|
|
25
|
+
'undefined',
|
|
26
|
+
'null',
|
|
27
|
+
'true',
|
|
28
|
+
'false',
|
|
29
|
+
'node_modules',
|
|
30
|
+
'package',
|
|
31
|
+
'dist',
|
|
32
|
+
'build'
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a package name.
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {Set<string>} [existingNames] - Names already in the workspace
|
|
39
|
+
* @returns {string|true} true if valid, or an error message string
|
|
40
|
+
*/
|
|
41
|
+
export function validatePackageName(name, existingNames) {
|
|
42
|
+
if (!name) return 'Name is required'
|
|
43
|
+
if (!/^[a-z0-9-]+$/.test(name))
|
|
44
|
+
return 'Use lowercase letters, numbers, and hyphens'
|
|
45
|
+
if (RESERVED_NAMES.has(name))
|
|
46
|
+
return `"${name}" is a reserved name — choose a different one`
|
|
47
|
+
if (existingNames?.has(name))
|
|
48
|
+
return `"${name}" already exists in this workspace`
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discover all package names in the workspace (foundations + sites + extensions).
|
|
54
|
+
* @param {string} rootDir - Workspace root directory
|
|
55
|
+
* @returns {Promise<Set<string>>}
|
|
56
|
+
*/
|
|
57
|
+
export async function getExistingPackageNames(rootDir) {
|
|
58
|
+
const names = new Set()
|
|
59
|
+
|
|
60
|
+
// Foundations and sites via existing discovery
|
|
61
|
+
const foundations = await discoverFoundations(rootDir)
|
|
62
|
+
const sites = await discoverSites(rootDir)
|
|
63
|
+
|
|
64
|
+
// An extension is a foundation that declares `extension: true`, so the
|
|
65
|
+
// discovery above already has it, wherever its folder is. (A second scan of
|
|
66
|
+
// `extensions/*` stood here until 2026-10-08, when extensions stopped being
|
|
67
|
+
// placed there by default.)
|
|
68
|
+
for (const f of foundations) names.add(f.name)
|
|
69
|
+
for (const s of sites) names.add(s.name)
|
|
70
|
+
|
|
71
|
+
return names
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a unique name by appending a suffix if there's a collision.
|
|
76
|
+
* @param {string} name - Proposed name
|
|
77
|
+
* @param {string} suffix - Suffix to append (e.g., '-site', '-foundation')
|
|
78
|
+
* @param {Set<string>} existingNames
|
|
79
|
+
* @returns {string} The resolved unique name
|
|
80
|
+
*/
|
|
81
|
+
export function resolveUniqueName(name, suffix, existingNames) {
|
|
82
|
+
if (!existingNames.has(name)) return name
|
|
83
|
+
const suffixed = `${name}${suffix}`
|
|
84
|
+
if (!existingNames.has(suffixed)) return suffixed
|
|
85
|
+
// Unlikely: both name and name-suffix taken — append number
|
|
86
|
+
for (let i = 2; i < 100; i++) {
|
|
87
|
+
const numbered = `${name}${suffix}-${i}`
|
|
88
|
+
if (!existingNames.has(numbered)) return numbered
|
|
89
|
+
}
|
|
90
|
+
return suffixed // give up
|
|
91
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Package placement resolution — shared by `add` and `clone`.
|
|
5
|
+
*
|
|
6
|
+
* Kept dependency-free (no `@uniweb/build`, no fs) on purpose: `clone` imports
|
|
7
|
+
* it and must run from a global install before any project — anything that
|
|
8
|
+
* statically pulls in `@uniweb/build` would crash `npx uniweb@latest clone`
|
|
9
|
+
* (the same reason `utils/workspace.js` loads the classifier lazily).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// `--path` is documented as a folder INSIDE the workspace, and every consumer of
|
|
13
|
+
// the result treats it that way: the caller does `join(rootDir, relativePath)`
|
|
14
|
+
// while the workspace manifests get `relativePath` verbatim. An ABSOLUTE path
|
|
15
|
+
// silently satisfies neither — `join('/ws', '/tmp/x')` is `/ws/tmp/x`, so the
|
|
16
|
+
// directory is created in one place while `package.json::workspaces` and
|
|
17
|
+
// `pnpm-workspace.yaml` record `/tmp/x`. The two disagree, and the manifests are
|
|
18
|
+
// committed files carrying a path that exists on exactly one machine.
|
|
19
|
+
//
|
|
20
|
+
// Reported by the backend lane 2026-09-18 from a `clone --path <abs>`: the site
|
|
21
|
+
// landed under `company-websites/tmp/claude-502/clonetest/` and both manifests
|
|
22
|
+
// gained the absolute string.
|
|
23
|
+
//
|
|
24
|
+
// So: accept an absolute path when it names a folder inside the root (people type
|
|
25
|
+
// one, and it is unambiguous), by rewriting it to the root-relative form every
|
|
26
|
+
// consumer already expects. Refuse it when it escapes — a workspace package cannot
|
|
27
|
+
// live outside its workspace, and guessing which half the user meant is how you get
|
|
28
|
+
// the split above.
|
|
29
|
+
function normalizeParent(given, rootDir) {
|
|
30
|
+
const trimmed = String(given).replace(/\/+$/, '')
|
|
31
|
+
if (!isAbsolute(trimmed)) return { relative: trimmed }
|
|
32
|
+
const rel = relative(rootDir || '', trimmed)
|
|
33
|
+
if (!rel || rel === '..' || rel.startsWith('../')) return { outsideRoot: true, given: trimmed }
|
|
34
|
+
return { relative: rel.split(sep).join('/') }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Foundation placement defaults (folder `src/`, package `src`). */
|
|
38
|
+
export const FOUNDATION_KIND = {
|
|
39
|
+
defaultDir: 'src',
|
|
40
|
+
defaultPkg: 'src',
|
|
41
|
+
projectSub: 'src'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Site placement defaults (folder `site/`, package `site`). */
|
|
45
|
+
export const SITE_KIND = {
|
|
46
|
+
defaultDir: 'site',
|
|
47
|
+
defaultPkg: 'site',
|
|
48
|
+
projectSub: 'site'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extension placement: no defaults — `add extension` always has a name, or a
|
|
53
|
+
* `--path` naming the folder, so neither a default folder nor the co-located
|
|
54
|
+
* `--project` form is reached. ⛔ Until 2026-10-08 an extension went to
|
|
55
|
+
* `extensions/<name>/` whatever was asked, `--path` named the folder itself
|
|
56
|
+
* rather than the one it goes in, and the workspace glob added was always
|
|
57
|
+
* `extensions/*` — so `--path elsewhere/fx` made a package pnpm never saw.
|
|
58
|
+
*/
|
|
59
|
+
export const EXTENSION_KIND = {
|
|
60
|
+
defaultDir: null,
|
|
61
|
+
defaultPkg: null,
|
|
62
|
+
projectSub: null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve where a foundation or site should be placed, given the user's input.
|
|
67
|
+
*
|
|
68
|
+
* The rule: **the user names a folder, and we create exactly that folder.**
|
|
69
|
+
* No silent nesting under `foundations/` / `sites/`, no inferring layout from
|
|
70
|
+
* pre-existing globs. The framework doesn't require any particular folder
|
|
71
|
+
* structure (the build classifies packages by their contents, not their
|
|
72
|
+
* location), so the CLI shouldn't impose one.
|
|
73
|
+
*
|
|
74
|
+
* Resolution priority (foundation example, same shape for site):
|
|
75
|
+
*
|
|
76
|
+
* 1. `--path <dir>` → explicit folder. Name is the path's
|
|
77
|
+
* last segment (used as the package
|
|
78
|
+
* name unless `name` was also given).
|
|
79
|
+
* 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
|
|
80
|
+
* Folder = the path, package name =
|
|
81
|
+
* the last segment.
|
|
82
|
+
* 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
|
|
83
|
+
* 4. `--project <project>` → folder = `<project>/<defaultSub>` and
|
|
84
|
+
* package name = `<project>-<defaultSub>`
|
|
85
|
+
* (the co-located convention; only this
|
|
86
|
+
* one uses the `-src` / `-site` suffix).
|
|
87
|
+
* 5. (no input) → folder = `<defaultDir>/`, package name
|
|
88
|
+
* = `<defaultPkg>` (`src/` + `src`
|
|
89
|
+
* for foundations; `site/` + `site` for
|
|
90
|
+
* sites).
|
|
91
|
+
*
|
|
92
|
+
* @param {string} rootDir
|
|
93
|
+
* @param {string|null} name - Either a bare name or a path-with-slash.
|
|
94
|
+
* @param {{ path?: string, project?: string }} opts
|
|
95
|
+
* @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
|
|
96
|
+
* @returns {{ relativePath: string, packageName: string }}
|
|
97
|
+
*/
|
|
98
|
+
export function resolvePlacement(rootDir, name, opts, kind) {
|
|
99
|
+
// 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
|
|
100
|
+
// name was given, or `<path>` itself if not (the path's last segment
|
|
101
|
+
// is then taken as the package name).
|
|
102
|
+
if (opts.path) {
|
|
103
|
+
const parent = normalizeParent(opts.path, rootDir)
|
|
104
|
+
if (parent.outsideRoot) return { outsideRoot: parent.given, relativePath: null, packageName: null }
|
|
105
|
+
const dir = parent.relative
|
|
106
|
+
if (name) {
|
|
107
|
+
const last = name.split('/').filter(Boolean).pop()
|
|
108
|
+
return {
|
|
109
|
+
relativePath: `${dir}/${name}`.replace(/\/+/g, '/'),
|
|
110
|
+
packageName: last
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const lastSegment = dir.split('/').filter(Boolean).pop() || dir
|
|
114
|
+
return {
|
|
115
|
+
relativePath: dir,
|
|
116
|
+
packageName: lastSegment
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. name contains a slash → treat as a path.
|
|
121
|
+
if (name && name.includes('/')) {
|
|
122
|
+
const relativePath = name.replace(/\/+$/, '')
|
|
123
|
+
const lastSegment = relativePath.split('/').filter(Boolean).pop()
|
|
124
|
+
return {
|
|
125
|
+
relativePath,
|
|
126
|
+
packageName: lastSegment
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Bare name.
|
|
131
|
+
if (name) {
|
|
132
|
+
return {
|
|
133
|
+
relativePath: name,
|
|
134
|
+
packageName: name
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. --project (co-located convention with -src / -site suffix).
|
|
139
|
+
if (opts.project) {
|
|
140
|
+
return {
|
|
141
|
+
relativePath: `${opts.project}/${kind.projectSub}`,
|
|
142
|
+
packageName: `${opts.project}-${kind.projectSub}`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 5. Default placement.
|
|
147
|
+
return {
|
|
148
|
+
relativePath: kind.defaultDir,
|
|
149
|
+
packageName: kind.defaultPkg
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/utils/pm.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package Manager Detection
|
|
3
|
+
*
|
|
4
|
+
* Detect whether the user ran the CLI via npm or pnpm,
|
|
5
|
+
* and generate PM-appropriate commands for output messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { execSync } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect which package manager invoked the CLI.
|
|
14
|
+
* Uses the standard npm_config_user_agent env var (same technique as create-vite, create-next-app).
|
|
15
|
+
*
|
|
16
|
+
* Note: this returns the *invoker* PM, which is empty when the user runs
|
|
17
|
+
* a global CLI binary directly from a shell. For the workspace's own PM
|
|
18
|
+
* (driven by lockfile presence), use `detectWorkspacePm`.
|
|
19
|
+
*
|
|
20
|
+
* @returns {'pnpm' | 'npm'}
|
|
21
|
+
*/
|
|
22
|
+
export function detectPackageManager() {
|
|
23
|
+
const ua = process.env.npm_config_user_agent || ''
|
|
24
|
+
if (ua.startsWith('pnpm/')) return 'pnpm'
|
|
25
|
+
return 'npm'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect the workspace's package manager by inspecting lockfiles at the
|
|
30
|
+
* workspace root. This is the right signal for "what PM should I use to
|
|
31
|
+
* install in this workspace" — independent of how the CLI was invoked.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} workspaceRoot - Absolute path to workspace root
|
|
34
|
+
* @returns {'pnpm' | 'yarn' | 'npm' | null} - null when no lockfile is present
|
|
35
|
+
*/
|
|
36
|
+
export function detectWorkspacePm(workspaceRoot) {
|
|
37
|
+
if (!workspaceRoot) return null
|
|
38
|
+
if (existsSync(join(workspaceRoot, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
39
|
+
if (existsSync(join(workspaceRoot, 'yarn.lock'))) return 'yarn'
|
|
40
|
+
if (existsSync(join(workspaceRoot, 'package-lock.json'))) return 'npm'
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pnpm major that actually installed this workspace.
|
|
46
|
+
*
|
|
47
|
+
* pnpm records the version it ran as in `node_modules/.modules.yaml`:
|
|
48
|
+
*
|
|
49
|
+
* "packageManager": "pnpm@10.30.0"
|
|
50
|
+
*
|
|
51
|
+
* This is a *record of what happened*, not a guess about intent — which
|
|
52
|
+
* matters, because intent cannot be inferred. The documented way to
|
|
53
|
+
* scaffold is `npm create uniweb`, and plenty of people follow it with
|
|
54
|
+
* `pnpm install`; the invoking package manager therefore says nothing
|
|
55
|
+
* about the one the project uses. The lockfile says WHICH manager
|
|
56
|
+
* (`detectWorkspacePm`); this says which pnpm MAJOR, which the lockfile
|
|
57
|
+
* cannot — pnpm 10 and 11 both write `lockfileVersion: 9.0`.
|
|
58
|
+
*
|
|
59
|
+
* Despite the extension the file is JSON in current pnpm; `js-yaml`
|
|
60
|
+
* parses both, since YAML is a superset. Older pnpm wrote real YAML.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} workspaceRoot
|
|
63
|
+
* @returns {string|null} pnpm major (e.g. '10'), or null when the file is
|
|
64
|
+
* absent or unreadable — callers fall back to their own default.
|
|
65
|
+
*/
|
|
66
|
+
export function detectInstalledPnpmVersion(workspaceRoot) {
|
|
67
|
+
if (!workspaceRoot) return null
|
|
68
|
+
const path = join(workspaceRoot, 'node_modules', '.modules.yaml')
|
|
69
|
+
if (!existsSync(path)) return null
|
|
70
|
+
try {
|
|
71
|
+
// Cheap targeted read: the file is large (hoisted-dependency maps run
|
|
72
|
+
// to hundreds of KB) and we want exactly one field, so match it
|
|
73
|
+
// directly rather than parsing the whole document.
|
|
74
|
+
const text = readFileSync(path, 'utf8')
|
|
75
|
+
// Quotes optional on both key and value: current pnpm writes JSON
|
|
76
|
+
// (`"packageManager": "pnpm@10.30.0"`), but the file is named .yaml and
|
|
77
|
+
// YAML permits the bare form, so accept either rather than depending on
|
|
78
|
+
// which serializer the installing pnpm happened to use.
|
|
79
|
+
const match = text.match(/["']?packageManager["']?\s*:\s*["']?pnpm@(\d+)/)
|
|
80
|
+
return match ? match[1] : null
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detect which package manager owns a *globally installed* `uniweb` CLI
|
|
88
|
+
* binary, by inspecting its install path (`process.argv[1]`). pnpm and
|
|
89
|
+
* yarn keep global packages under recognizable directory segments;
|
|
90
|
+
* everything else is assumed to be npm. Only meaningful when the CLI is
|
|
91
|
+
* actually a global install (see index.js::isGlobalInstall) — a
|
|
92
|
+
* project-local or npx-launched copy is updated differently.
|
|
93
|
+
*
|
|
94
|
+
* @returns {'pnpm' | 'yarn' | 'npm'}
|
|
95
|
+
*/
|
|
96
|
+
export function detectGlobalCliPm() {
|
|
97
|
+
const path = (process.argv[1] || '').toLowerCase().replace(/\\/g, '/')
|
|
98
|
+
if (path.includes('/pnpm/')) return 'pnpm'
|
|
99
|
+
if (path.includes('/yarn/')) return 'yarn'
|
|
100
|
+
return 'npm'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The command to (re)install the latest `uniweb` CLI globally with a
|
|
105
|
+
* given package manager.
|
|
106
|
+
* @param {'pnpm' | 'yarn' | 'npm'} pm
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function globalCliUpdateCmd(pm) {
|
|
110
|
+
if (pm === 'pnpm') return 'pnpm add -g uniweb@latest'
|
|
111
|
+
if (pm === 'yarn') return 'yarn global add uniweb@latest'
|
|
112
|
+
return 'npm i -g uniweb@latest'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate a workspace-filtered command.
|
|
117
|
+
* pnpm: "pnpm --filter site dev"
|
|
118
|
+
* npm: "npm -w site run dev"
|
|
119
|
+
* @param {'pnpm' | 'npm'} pm
|
|
120
|
+
* @param {string} pkg - Package name to filter to
|
|
121
|
+
* @param {string} cmd - Script name to run
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function filterCmd(pm, pkg, cmd) {
|
|
125
|
+
return pm === 'pnpm'
|
|
126
|
+
? `pnpm --filter ${pkg} ${cmd}`
|
|
127
|
+
: `npm -w ${pkg} run ${cmd}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether pnpm is available on the current PATH. Used to recommend pnpm
|
|
132
|
+
* (the framework's package manager) in post-scaffold next steps while still
|
|
133
|
+
* falling back gracefully when it isn't installed. Mirrors the `git --version`
|
|
134
|
+
* probe the create flow already uses to feature-detect git.
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function isPnpmAvailable() {
|
|
138
|
+
try {
|
|
139
|
+
execSync('pnpm --version', { stdio: 'ignore' })
|
|
140
|
+
return true
|
|
141
|
+
} catch {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate an install command.
|
|
148
|
+
* @param {'pnpm' | 'yarn' | 'npm'} pm
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function installCmd(pm) {
|
|
152
|
+
if (pm === 'pnpm') return 'pnpm install'
|
|
153
|
+
if (pm === 'yarn') return 'yarn install'
|
|
154
|
+
return 'npm install'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate a run-script command.
|
|
159
|
+
* @param {'pnpm' | 'npm'} pm
|
|
160
|
+
* @param {string} script - Script name
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function runCmd(pm, script) {
|
|
164
|
+
return pm === 'pnpm' ? `pnpm ${script}` : `npm run ${script}`
|
|
165
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `site.yml::preview` — the site's card image — has two writers: an author, who
|
|
3
|
+
* writes an address (a URL, or a path to an image in the project), and the app,
|
|
4
|
+
* which writes a token naming an image it generated. This is the one test that
|
|
5
|
+
* tells them apart.
|
|
6
|
+
*
|
|
7
|
+
* It recognizes the author's shapes rather than the app's, so it does not depend
|
|
8
|
+
* on the token's format: a URL, a path starting `/`, `./` or `../`, or a relative
|
|
9
|
+
* path to an image file (`images/card.png`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const IMAGE_FILE = /\.(avif|gif|jpe?g|png|svg|webp)$/i
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {unknown} value
|
|
16
|
+
* @returns {boolean} true when `value` is an author's address rather than the app's token
|
|
17
|
+
*/
|
|
18
|
+
export const isAuthoredPreview = (value) =>
|
|
19
|
+
typeof value === 'string' &&
|
|
20
|
+
(/^https?:\/\//i.test(value) || /^\.{0,2}\//.test(value) || IMAGE_FILE.test(value.trim()))
|