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,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
+ }
@@ -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()))