uniweb 0.12.10 → 0.12.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.10",
3
+ "version": "0.12.12",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/core": "0.7.11",
44
45
  "@uniweb/runtime": "0.8.13",
45
- "@uniweb/kit": "0.9.11",
46
- "@uniweb/core": "0.7.11"
46
+ "@uniweb/kit": "0.9.11"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "0.14.2",
50
- "@uniweb/semantic-parser": "1.1.17",
51
- "@uniweb/content-reader": "1.1.10"
50
+ "@uniweb/content-reader": "1.1.10",
51
+ "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -136,11 +136,28 @@ Creates `sections/Hero/index.jsx` and `meta.js` with a minimal CCA-proper starte
136
136
  ## Commands
137
137
 
138
138
  ```bash
139
- pnpm install # Install dependencies
140
- pnpm dev # Start dev server
141
- pnpm build # Build for production
142
- pnpm preview # Preview production build (SSG + SPA)
143
- ```
139
+ # Local development
140
+ uniweb dev # Start dev server (picks the site for you)
141
+ pnpm install # Install dependencies
142
+ pnpm build # Build for production
143
+ pnpm preview # Preview production build (SSG + SPA)
144
+
145
+ # Ship the site (uniweb verbs)
146
+ uniweb deploy # Deploy to Uniweb hosting (default; needs `uniweb login` first)
147
+ uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, netlify,
148
+ # vercel, github-pages, s3-cloudfront, generic-static
149
+ uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
150
+ uniweb export # Build dist/ for any static host (no Uniweb account)
151
+ uniweb publish # Publish a foundation as a catalog product (deliberate;
152
+ # for site-bound foundations use `uniweb deploy` instead)
153
+ uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
154
+
155
+ # Help
156
+ uniweb --help # Top-level help
157
+ uniweb <command> --help # Per-command help (no side effects)
158
+ ```
159
+
160
+ `uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb publish` step needed for site-bound foundations.
144
161
 
145
162
  ---
146
163
 
@@ -389,7 +389,7 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
389
389
  success(`Created site ${colors.bright}${siteName}${colors.reset} at ${relativePath}/`)
390
390
  }
391
391
  log('')
392
- log(`Next: ${colors.cyan}${installCmd(pm)} && ${filterCmd(pm, siteName, 'dev')}${colors.reset}`)
392
+ log(`Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${siteName}${colors.reset}`)
393
393
  if (!opts.from) {
394
394
  log('')
395
395
  log(`${colors.dim}To add your first page, create ${relativePath}/pages/home/page.yml and a .md file.${colors.reset}`)
@@ -631,7 +631,7 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
631
631
  log(` ${colors.dim}Foundation: ${name}/src/ (${foundationPkgName})${colors.reset}`)
632
632
  log(` ${colors.dim}Site: ${name}/site/ (${sitePkgName})${colors.reset}`)
633
633
  log('')
634
- log(`Next: ${colors.cyan}${installCmd(pm)} && ${filterCmd(pm, sitePkgName, 'dev')}${colors.reset}`)
634
+ log(`Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${sitePkgName}${colors.reset}`)
635
635
  }
636
636
 
637
637
  /**
@@ -401,6 +401,21 @@ export async function deploy(args = []) {
401
401
  // of the current source's git sha. This flag opts out.
402
402
  const autoPublishFoundation = !args.includes('--no-auto-publish')
403
403
 
404
+ // --local: redirect platform URLs to the unicloud mock (localhost:4001)
405
+ // for internal end-to-end testing. Documented in the workspace root
406
+ // CLAUDE.md ("The --local Flag" section). NOT a public user-facing
407
+ // feature — a real user has no unicloud server running. The flag is
408
+ // intentionally absent from the global help to avoid leaking it into
409
+ // user docs; per-command help (uniweb deploy --help) lists it under
410
+ // an "Internal" caveat for the eval / test team.
411
+ //
412
+ // The override unconditionally pins both backend and worker to
413
+ // http://localhost:4001 (unicloud's default port) regardless of any
414
+ // env vars set in the calling shell. Auth is NOT skipped — the runbook
415
+ // expects mock-login.js to seed ~/.uniweb/auth.json with a JWT
416
+ // unicloud's verifyToken accepts.
417
+ const isLocal = args.includes('--local')
418
+
404
419
  // Internal escape hatches — see framework/cli/docs/env-vars.md. These
405
420
  // are not user-facing flags; they exist for the platform test team,
406
421
  // CI scripts, and dev-loop unblockers. The bare `deploy` command should
@@ -415,8 +430,11 @@ export async function deploy(args = []) {
415
430
  const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
416
431
 
417
432
  const siteDir = await resolveSiteDir(args)
418
- const backendUrl = getBackendUrl()
419
- const workerUrl = getRegistryUrl()
433
+ const backendUrl = isLocal ? 'http://localhost:4001' : getBackendUrl()
434
+ const workerUrl = isLocal ? 'http://localhost:4001' : getRegistryUrl()
435
+ if (isLocal) {
436
+ console.log(` \x1b[2m→ Local mock mode (unicloud at ${backendUrl}; see workspace root CLAUDE.md)\x1b[0m`)
437
+ }
420
438
 
421
439
  // Read site.yml — declares the foundation (required) and optionally the
422
440
  // site.id / site.handle from prior deploys.
@@ -599,7 +617,7 @@ export async function deploy(args = []) {
599
617
  // doesn't fail the whole deploy.
600
618
  const desiredFeatures = readFeaturesFromYaml(siteYml)
601
619
 
602
- const cliToken = await ensureAuth({ command: 'Deploying' })
620
+ const cliToken = await ensureAuth({ command: 'Deploying', args })
603
621
 
604
622
  // Always rebuild unless the user explicitly opts out with --skip-build.
605
623
  // A stale dist/ from a previous build + edited content on disk would
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Dev Command
3
+ *
4
+ * Starts a dev server for a site in the current workspace. Wraps the
5
+ * project's `dev` script (set up by `uniweb create` to filter to the
6
+ * appropriate site package). Provides discoverability and consistency
7
+ * with `uniweb build` / `uniweb deploy` — users shouldn't have to know
8
+ * whether to type `pnpm dev` or `npm run dev` when the rest of the CLI
9
+ * is verb-shaped.
10
+ *
11
+ * Usage:
12
+ * uniweb dev Start dev server for the (single) site
13
+ * uniweb dev <site> Start dev server for a specific site
14
+ * uniweb dev --site <name> Same, with explicit flag form
15
+ *
16
+ * Resolution order for which site to launch:
17
+ * 1. --site <name> (if passed)
18
+ * 2. Positional <site> arg
19
+ * 3. The single site in the workspace (if exactly one)
20
+ * 4. The first site in the workspace, with a "multiple sites" notice
21
+ * pointing at --site for explicit selection
22
+ *
23
+ * Multi-site workspaces with no positional / flag will run the first
24
+ * site by default (mirrors the `pnpm dev` shortcut `uniweb create` writes).
25
+ * Use `--site` to pick a different one without editing the root scripts.
26
+ *
27
+ * Implementation: shells out to the package manager that invoked the CLI
28
+ * (detected via npm_config_user_agent), running the workspace-filtered
29
+ * dev command (`pnpm --filter <name> dev` or `npm -w <name> run dev`).
30
+ * No special handling of vite directly — the site package already owns
31
+ * its dev script, and shelling through pnpm/npm respects whatever the
32
+ * site has configured (Vite plugins, env vars, port overrides, etc.).
33
+ */
34
+
35
+ import { spawn } from 'node:child_process'
36
+ import { join } from 'node:path'
37
+
38
+ import { detectPackageManager, filterCmd } from '../utils/pm.js'
39
+ import { discoverSites, readWorkspaceConfig } from '../utils/config.js'
40
+ import { findWorkspaceRoot } from '../utils/workspace.js'
41
+ import { readFlagValue } from '../utils/args.js'
42
+
43
+ const RED = '\x1b[31m'
44
+ const YELLOW = '\x1b[33m'
45
+ const DIM = '\x1b[2m'
46
+ const CYAN = '\x1b[36m'
47
+ const RESET = '\x1b[0m'
48
+
49
+ export async function dev(args = []) {
50
+ const cwd = process.cwd()
51
+ const rootDir = findWorkspaceRoot(cwd) || cwd
52
+
53
+ // Verify we're in a Uniweb workspace (has pnpm-workspace.yaml or
54
+ // package.json::workspaces). discoverSites already handles both.
55
+ let workspaceConfig
56
+ try {
57
+ workspaceConfig = await readWorkspaceConfig(rootDir)
58
+ } catch {
59
+ workspaceConfig = { packages: [] }
60
+ }
61
+ if (workspaceConfig.packages.length === 0) {
62
+ console.error(`${RED}✗${RESET} Not in a Uniweb workspace (no pnpm-workspace.yaml or package.json::workspaces).`)
63
+ console.error(` Run \`uniweb create <name>\` to scaffold a project, or cd into an existing one.`)
64
+ process.exit(1)
65
+ }
66
+
67
+ const sites = await discoverSites(rootDir)
68
+ if (sites.length === 0) {
69
+ console.error(`${RED}✗${RESET} No sites found in this workspace.`)
70
+ console.error(` Add one with \`uniweb add site <name>\`.`)
71
+ process.exit(1)
72
+ }
73
+
74
+ // Pick the site
75
+ const siteFlag = readFlagValue(args, '--site')
76
+ const positional = args.find(a => !a.startsWith('-'))
77
+ const requested = (typeof siteFlag === 'string' ? siteFlag : null) || positional || null
78
+
79
+ let site
80
+ if (requested) {
81
+ site = sites.find(s => s.name === requested) || sites.find(s => s.path === requested)
82
+ if (!site) {
83
+ console.error(`${RED}✗${RESET} Site "${requested}" not found.`)
84
+ console.error(` Available: ${sites.map(s => s.name).join(', ')}`)
85
+ process.exit(1)
86
+ }
87
+ } else if (sites.length === 1) {
88
+ site = sites[0]
89
+ } else {
90
+ site = sites[0]
91
+ console.error(`${YELLOW}⚠${RESET} Multiple sites found; using ${CYAN}${site.name}${RESET}.`)
92
+ console.error(` Pick a different one with \`uniweb dev --site <name>\`.`)
93
+ console.error(` Available: ${sites.map(s => s.name).join(', ')}`)
94
+ console.error('')
95
+ }
96
+
97
+ const pm = detectPackageManager()
98
+ const command = filterCmd(pm, site.name, 'dev')
99
+ const [bin, ...rest] = command.split(' ')
100
+ const sitePath = join(rootDir, site.path)
101
+
102
+ console.error(`${DIM}→ ${command}${RESET} ${DIM}(site: ${site.name}, dir: ${sitePath})${RESET}`)
103
+ console.error('')
104
+
105
+ const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit' })
106
+ child.on('close', code => process.exit(code ?? 0))
107
+ child.on('error', err => {
108
+ console.error(`${RED}✗${RESET} Failed to start dev server: ${err.message}`)
109
+ process.exit(1)
110
+ })
111
+ }
@@ -145,7 +145,7 @@ async function readSchema(foundationDir) {
145
145
  * Create a RemoteRegistry instance with auth.
146
146
  */
147
147
  async function createRegistry(args) {
148
- const token = await ensureAuth({ command: 'Handing off' })
148
+ const token = await ensureAuth({ command: 'Handing off', args })
149
149
 
150
150
  const registryUrl = parseFlag(args, '--registry')
151
151
  const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
@@ -146,7 +146,7 @@ async function readSchema(foundationDir) {
146
146
  * Create a RemoteRegistry instance with auth.
147
147
  */
148
148
  async function createRegistry(args) {
149
- const token = await ensureAuth({ command: 'Creating invite' })
149
+ const token = await ensureAuth({ command: 'Creating invite', args })
150
150
 
151
151
  const registryUrl = parseFlag(args, '--registry')
152
152
  const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
@@ -794,7 +794,7 @@ export async function publish(args = []) {
794
794
  registry = createLocalRegistry(foundationDir)
795
795
  } else {
796
796
  // Remote publish — ensure authenticated (inline login if needed)
797
- const token = await ensureAuth({ command: 'Publishing' })
797
+ const token = await ensureAuth({ command: 'Publishing', args })
798
798
 
799
799
  const url = registryUrl || getRegistryUrl()
800
800
  registry = new RemoteRegistry(url, token)
@@ -191,7 +191,7 @@ async function templatePublish(args) {
191
191
  console.log(` ${colors.dim}${fileCount} files${colors.reset}`)
192
192
 
193
193
  // 5. Authenticate
194
- const token = await ensureAuth({ command: 'Publishing template' })
194
+ const token = await ensureAuth({ command: 'Publishing template', args })
195
195
 
196
196
  // 6. Build payload
197
197
  const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
@@ -1,16 +1,47 @@
1
1
  /**
2
- * uniweb update - Update generated project files
2
+ * uniweb update Update the CLI itself, and (in a Uniweb project) the
3
+ * project's AGENTS.md.
3
4
  *
4
- * Regenerates AGENTS.md from the installed CLI version.
5
+ * Two responsibilities, in priority order:
6
+ *
7
+ * 1. **Self-update the global install.** Most users running `uniweb update`
8
+ * expect the verb to update the CLI binary itself (this is what `npm
9
+ * update -g`, `gh update`, `claude update`, etc. all do). The CLI
10
+ * detects the relevant package manager and runs the global-install
11
+ * command for it (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`,
12
+ * `yarn global add uniweb@latest`). In TTY, prompts before executing.
13
+ * In non-interactive mode, prints the command and exits — never runs an
14
+ * unconfirmed self-update from a script.
15
+ *
16
+ * 2. **Refresh AGENTS.md** (only when the cwd resolves to a *Uniweb*
17
+ * project — checked via `package.json::devDependencies::uniweb` or
18
+ * `dependencies::uniweb` at the workspace root). The previous
19
+ * implementation walked up looking for ANY pnpm-workspace.yaml or
20
+ * `package.json::workspaces` root, which falsely identified unrelated
21
+ * monorepos as Uniweb projects and wrote AGENTS.md into them.
22
+ *
23
+ * Flags:
24
+ * --agents-only Skip self-update; only refresh AGENTS.md.
25
+ * --no-agents Skip AGENTS.md; only self-update.
26
+ * --yes Skip the confirmation prompt before self-update.
27
+ * --non-interactive Auto-detected; never runs unconfirmed self-update.
28
+ *
29
+ * Project-local case (CLI lives in node_modules, not global): self-update
30
+ * isn't possible — that's a project decision (bump the dep in
31
+ * package.json). The verb prints that explanation and proceeds with the
32
+ * AGENTS.md refresh path only.
5
33
  */
6
34
 
7
- import { existsSync, writeFileSync } from 'node:fs'
8
- import { join, resolve } from 'node:path'
35
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
36
+ import { join } from 'node:path'
37
+ import { spawn } from 'node:child_process'
38
+ import prompts from 'prompts'
39
+
9
40
  import { findWorkspaceRoot } from '../utils/workspace.js'
10
41
  import { readAgentsVersion, generateAgentsContent } from '../utils/agents-stamp.js'
11
42
  import { getCliVersion } from '../versions.js'
43
+ import { isNonInteractive } from '../utils/interactive.js'
12
44
 
13
- // ANSI colors
14
45
  const colors = {
15
46
  reset: '\x1b[0m',
16
47
  bright: '\x1b[1m',
@@ -18,7 +49,7 @@ const colors = {
18
49
  red: '\x1b[31m',
19
50
  green: '\x1b[32m',
20
51
  yellow: '\x1b[33m',
21
- blue: '\x1b[36m'
52
+ cyan: '\x1b[36m',
22
53
  }
23
54
 
24
55
  const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
@@ -26,30 +57,196 @@ const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
26
57
  const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
27
58
  const log = console.log
28
59
 
29
- export async function update(args = []) {
30
- const workspaceDir = findWorkspaceRoot(process.cwd())
60
+ /**
61
+ * Detect whether this CLI is running from a global install. Mirrors the
62
+ * logic in index.js::isGlobalInstall — when global, process.argv[1]
63
+ * points outside any node_modules.
64
+ */
65
+ function isGlobalInstall() {
66
+ const scriptPath = process.argv[1]
67
+ if (!scriptPath) return false
68
+ return !scriptPath.split('/').includes('node_modules') &&
69
+ !scriptPath.split('\\').includes('node_modules')
70
+ }
31
71
 
32
- if (!workspaceDir) {
33
- error('Not in a Uniweb workspace')
34
- log(`${colors.dim}Run this command from your project root or a package directory.${colors.reset}`)
35
- process.exit(1)
72
+ /**
73
+ * Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
74
+ * also requires that the workspace's root package.json declares uniweb
75
+ * as a dep or devDep. Otherwise the previous behavior (walking up to any
76
+ * pnpm-workspace.yaml) writes AGENTS.md into unrelated monorepos.
77
+ */
78
+ function findUniwebWorkspace(cwd) {
79
+ const workspaceDir = findWorkspaceRoot(cwd)
80
+ if (!workspaceDir) return null
81
+ const pkgPath = join(workspaceDir, 'package.json')
82
+ if (!existsSync(pkgPath)) return null
83
+ try {
84
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
85
+ const hasUniwebDep = !!(pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb)
86
+ return hasUniwebDep ? workspaceDir : null
87
+ } catch {
88
+ return null
36
89
  }
90
+ }
37
91
 
92
+ /**
93
+ * Detect the package manager that owns the global install. Heuristic
94
+ * based on the CLI's filesystem path — pnpm and yarn berry use distinctive
95
+ * directory layouts; npm is the fallback.
96
+ *
97
+ * @returns {'pnpm'|'yarn'|'npm'}
98
+ */
99
+ function detectGlobalPm() {
100
+ const path = (process.argv[1] || '').toLowerCase()
101
+ if (path.includes('/pnpm/') || path.includes('\\pnpm\\')) return 'pnpm'
102
+ if (path.includes('/yarn/') || path.includes('\\yarn\\')) return 'yarn'
103
+ return 'npm'
104
+ }
105
+
106
+ /**
107
+ * Build the global-install command for a given PM.
108
+ */
109
+ function globalInstallCmd(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
+ * Fetch the latest published version. Returns null on network error.
117
+ */
118
+ async function fetchLatestVersion() {
119
+ try {
120
+ const res = await fetch('https://registry.npmjs.org/uniweb/latest')
121
+ if (!res.ok) return null
122
+ const data = await res.json()
123
+ return data?.version || null
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Compare two semver strings: 1 if a>b, -1 if a<b, 0 if equal.
131
+ */
132
+ function compareSemver(a, b) {
133
+ const pa = a.split('.').map(Number)
134
+ const pb = b.split('.').map(Number)
135
+ for (let i = 0; i < 3; i++) {
136
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
137
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
138
+ }
139
+ return 0
140
+ }
141
+
142
+ /**
143
+ * Run a shell command, inheriting stdio. Resolves with the exit code.
144
+ */
145
+ function runCommand(cmd) {
146
+ return new Promise((resolve) => {
147
+ const [bin, ...rest] = cmd.split(' ')
148
+ const child = spawn(bin, rest, { stdio: 'inherit' })
149
+ child.on('close', code => resolve(code ?? 0))
150
+ child.on('error', () => resolve(1))
151
+ })
152
+ }
153
+
154
+ export async function update(args = []) {
155
+ const agentsOnly = args.includes('--agents-only')
156
+ const skipAgents = args.includes('--no-agents')
157
+ const skipPrompt = args.includes('--yes') || isNonInteractive(args)
158
+ const isGlobal = isGlobalInstall()
159
+ const workspaceDir = findUniwebWorkspace(process.cwd())
160
+ const inProject = !!workspaceDir
38
161
  const cliVersion = getCliVersion()
39
- const agentsPath = join(workspaceDir, 'AGENTS.md')
40
- const currentVersion = readAgentsVersion(agentsPath)
41
162
 
42
- if (currentVersion === cliVersion) {
43
- success(`AGENTS.md is already up to date (v${cliVersion})`)
163
+ // ─── Step 1: Self-update path ─────────────────────────────────
164
+ if (!agentsOnly) {
165
+ if (!isGlobal) {
166
+ // Project-local: can't self-update meaningfully.
167
+ log(`${colors.dim}Running the project-local CLI (v${cliVersion}). This copy is pinned by your${colors.reset}`)
168
+ log(`${colors.dim}project's package.json. To update it, bump${colors.reset} ${colors.cyan}uniweb${colors.reset}${colors.dim} in${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim} and re-install.${colors.reset}`)
169
+ log('')
170
+ } else {
171
+ const latest = await fetchLatestVersion()
172
+ if (latest === null) {
173
+ warn('Could not reach the npm registry to check for updates.')
174
+ log(`${colors.dim}Current: ${cliVersion}. Try later, or run${colors.reset} ${colors.cyan}${globalInstallCmd(detectGlobalPm())}${colors.reset}${colors.dim} manually.${colors.reset}`)
175
+ log('')
176
+ } else if (compareSemver(latest, cliVersion) <= 0) {
177
+ success(`uniweb is up to date (v${cliVersion}).`)
178
+ log('')
179
+ } else {
180
+ const pm = detectGlobalPm()
181
+ const cmd = globalInstallCmd(pm)
182
+ log(`${colors.yellow}Update available:${colors.reset} ${colors.dim}${cliVersion}${colors.reset} → ${colors.cyan}${latest}${colors.reset}`)
183
+ log(`${colors.dim}Detected package manager:${colors.reset} ${pm}`)
184
+ log(`${colors.dim}Will run:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
185
+ log('')
186
+
187
+ if (skipPrompt) {
188
+ log(`${colors.dim}Non-interactive — skipping self-update. Run the command above to update.${colors.reset}`)
189
+ log('')
190
+ } else {
191
+ const { go } = await prompts({
192
+ type: 'confirm',
193
+ name: 'go',
194
+ message: `Run \`${cmd}\` now?`,
195
+ initial: true,
196
+ })
197
+ if (go) {
198
+ const code = await runCommand(cmd)
199
+ if (code === 0) {
200
+ success(`Self-update complete.`)
201
+ } else {
202
+ error(`Self-update failed (exit ${code}). Run the command above manually if needed.`)
203
+ }
204
+ log('')
205
+ } else {
206
+ log(`${colors.dim}Skipped self-update.${colors.reset}`)
207
+ log('')
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // ─── Step 2: AGENTS.md refresh path ───────────────────────────
215
+ if (skipAgents) return
216
+ if (!inProject) {
217
+ if (agentsOnly) {
218
+ error('Not in a Uniweb project (no `uniweb` dep in the workspace root package.json).')
219
+ log(`${colors.dim}Run this command from inside a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`)
220
+ process.exit(1)
221
+ }
222
+ // Self-update-only path. Quietly skip AGENTS.md.
44
223
  return
45
224
  }
46
225
 
226
+ const agentsPath = join(workspaceDir, 'AGENTS.md')
227
+ const currentAgentsVersion = readAgentsVersion(agentsPath)
228
+ if (currentAgentsVersion === cliVersion) {
229
+ success(`AGENTS.md is already up to date (v${cliVersion}).`)
230
+ return
231
+ }
232
+
233
+ // Prompt before writing in TTY, unless --yes / non-interactive (in which
234
+ // case we err on the side of doing the right thing — refresh — since the
235
+ // user explicitly invoked `uniweb update` from a Uniweb project).
236
+ if (!skipPrompt && !agentsOnly) {
237
+ const action = currentAgentsVersion ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?` : `Create AGENTS.md (v${cliVersion})?`
238
+ const { yes } = await prompts({ type: 'confirm', name: 'yes', message: action, initial: true })
239
+ if (!yes) {
240
+ log(`${colors.dim}Skipped AGENTS.md.${colors.reset}`)
241
+ return
242
+ }
243
+ }
244
+
47
245
  const content = generateAgentsContent()
48
246
  writeFileSync(agentsPath, content)
49
-
50
- if (currentVersion) {
51
- success(`Updated AGENTS.md (v${currentVersion} → v${cliVersion})`)
247
+ if (currentAgentsVersion) {
248
+ success(`Updated AGENTS.md (v${currentAgentsVersion} → v${cliVersion}).`)
52
249
  } else {
53
- success(`Created AGENTS.md (v${cliVersion})`)
250
+ success(`Created AGENTS.md (v${cliVersion}).`)
54
251
  }
55
252
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-05T18:36:54.411Z",
3
+ "generatedAt": "2026-05-05T20:43:37.314Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.14.2",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.6",
95
+ "version": "0.4.7",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
package/src/index.js CHANGED
@@ -103,8 +103,12 @@ function getCliVersion() {
103
103
  /**
104
104
  * Commands that always run from the global CLI (no project context needed)
105
105
  */
106
+ // Commands that always run from the global CLI, never delegating to a
107
+ // project-local copy. `update` is here because its primary job is to
108
+ // self-update the GLOBAL install — delegating it to project-local would
109
+ // short-circuit that intent.
106
110
  const STANDALONE_COMMANDS = new Set([
107
- 'create', '--help', '-h', '--version', '-v', 'login',
111
+ 'create', '--help', '-h', '--version', '-v', 'login', 'update',
108
112
  ])
109
113
 
110
114
  /**
@@ -199,12 +203,16 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
199
203
 
200
204
  onProgress?.('Setting up workspace...')
201
205
 
202
- // 1. Scaffold workspace
206
+ // 1. Scaffold workspace.
207
+ // dev/build go through `uniweb` verbs so the scripts stay PM-agnostic
208
+ // (the verb resolves the right PM at runtime instead of locking the
209
+ // root scripts to whichever PM ran `npx uniweb create`). preview stays
210
+ // PM-filtered until a `uniweb preview` verb exists.
203
211
  await scaffoldWorkspace(projectDir, {
204
212
  projectName,
205
213
  workspaceGlobs: ['site', 'src'],
206
214
  scripts: {
207
- dev: filterCmd(pm, 'site', 'dev'),
215
+ dev: 'uniweb dev',
208
216
  build: 'uniweb build',
209
217
  preview: filterCmd(pm, 'site', 'preview'),
210
218
  },
@@ -286,17 +294,19 @@ async function createFromContentTemplate(projectDir, projectName, metadata, temp
286
294
  const scripts = {
287
295
  build: 'uniweb build',
288
296
  }
297
+ // dev goes through `uniweb` (PM-agnostic; see computeRootScripts).
298
+ // preview stays PM-filtered until a `uniweb preview` verb exists.
289
299
  if (sites.length === 1) {
290
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
300
+ scripts.dev = 'uniweb dev'
291
301
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
292
302
  } else {
293
303
  for (const s of sites) {
294
- scripts[`dev:${s.name}`] = filterCmd(pm, s.name, 'dev')
304
+ scripts[`dev:${s.name}`] = `uniweb dev ${s.name}`
295
305
  scripts[`preview:${s.name}`] = filterCmd(pm, s.name, 'preview')
296
306
  }
297
307
  // First site gets unqualified aliases
298
308
  if (sites.length > 0) {
299
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
309
+ scripts.dev = 'uniweb dev'
300
310
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
301
311
  }
302
312
  }
@@ -435,8 +445,20 @@ async function main() {
435
445
  const pm = detectPackageManager()
436
446
 
437
447
  // Handle --version / -v
448
+ //
449
+ // Output convention: the version goes to stdout (parseable, scriptable —
450
+ // `version=$(uniweb --version)` should keep working). Any staleness
451
+ // notice goes to stderr, so it shows in interactive terminals but
452
+ // doesn't pollute captured output. Cache-only — never makes a network
453
+ // call from this path.
438
454
  if (command === '--version' || command === '-v') {
439
455
  console.log(`uniweb ${getCliVersion()}`)
456
+ if (isGlobalInstall()) {
457
+ try {
458
+ const { maybeNotifyFromCache } = await import('./utils/update-check.js')
459
+ maybeNotifyFromCache(getCliVersion(), 'soft')
460
+ } catch { /* ignore */ }
461
+ }
440
462
  return
441
463
  }
442
464
 
@@ -452,12 +474,26 @@ async function main() {
452
474
  // Commands that need @uniweb/build will get a helpful error via importProjectCommand().
453
475
  }
454
476
 
455
- // Start non-blocking update check for global installs
477
+ // Start non-blocking update check for global installs.
478
+ //
479
+ // Two surfaces:
480
+ // - showUpdateNotification (soft, trailing): printed at command end for
481
+ // any verb. Doesn't interrupt the user's workflow.
482
+ // - eager (loud, leading): printed BEFORE staleness-sensitive verbs do
483
+ // their work. Today: only `create` (templates ship with the CLI, so
484
+ // a stale CLI scaffolds stale starter content; the user needs to know
485
+ // before files hit disk). Other verbs are insensitive — `deploy` etc.
486
+ // are project-bound (delegated to local node_modules), and the
487
+ // local-vs-global mismatch warning in delegateToLocal already covers
488
+ // that case.
456
489
  let showUpdateNotification = () => {}
457
490
  if (global) {
458
491
  try {
459
- const { startUpdateCheck } = await import('./utils/update-check.js')
492
+ const { startUpdateCheck, maybeEagerNotification } = await import('./utils/update-check.js')
460
493
  showUpdateNotification = startUpdateCheck(getCliVersion())
494
+ if (command === 'create') {
495
+ maybeEagerNotification(getCliVersion())
496
+ }
461
497
  } catch {
462
498
  // Update check is optional — don't fail if the module is missing
463
499
  }
@@ -470,6 +506,23 @@ async function main() {
470
506
  return
471
507
  }
472
508
 
509
+ // Per-command --help: short-circuit BEFORE the command's side effects run.
510
+ // Critical for `deploy --help` (used to open a browser to production for
511
+ // login because deploy.js doesn't parse --help and ensureAuth ran first).
512
+ // Falls back to the global help when a command has no dedicated block.
513
+ if (args.slice(1).some(a => a === '--help' || a === '-h')) {
514
+ const printed = printCommandHelp(command)
515
+ if (printed) {
516
+ await showUpdateNotification()
517
+ return
518
+ }
519
+ // No dedicated block — show global help as a useful fallback rather
520
+ // than executing the command (which often has side effects).
521
+ showHelp()
522
+ await showUpdateNotification()
523
+ return
524
+ }
525
+
473
526
  // Handle build command (dynamic import — depends on @uniweb/build)
474
527
  if (command === 'build') {
475
528
  const { build } = await importProjectCommand('./commands/build.js')
@@ -478,6 +531,16 @@ async function main() {
478
531
  return
479
532
  }
480
533
 
534
+ // Handle dev command — thin wrapper that shells to the package manager's
535
+ // workspace-filtered `dev` script (mirrors what `uniweb create` writes
536
+ // into the root package.json::scripts.dev). Lazy import keeps startup
537
+ // fast when the user is not running dev.
538
+ if (command === 'dev') {
539
+ const { dev } = await import('./commands/dev.js')
540
+ await dev(args.slice(1))
541
+ return
542
+ }
543
+
481
544
  // Handle docs command (dynamic import — depends on @uniweb/build)
482
545
  if (command === 'docs') {
483
546
  const { docs } = await importProjectCommand('./commands/docs.js')
@@ -807,18 +870,321 @@ async function main() {
807
870
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
808
871
  log(` ${colors.cyan}${prefix} add project${colors.reset}`)
809
872
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
810
- log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
873
+ log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
811
874
  } else {
812
875
  log(`Next steps:\n`)
813
876
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
814
877
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
815
- log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
878
+ log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
816
879
  }
817
880
  log('')
881
+ log(`When ready to ship:\n`)
882
+ log(` ${colors.cyan}${prefix} deploy${colors.reset} ${colors.dim}# Uniweb hosting (default; uniweb login first)${colors.reset}`)
883
+ log(` ${colors.cyan}${prefix} deploy --host=<adapter>${colors.reset} ${colors.dim}# cloudflare-pages, netlify, vercel, github-pages, s3-cloudfront${colors.reset}`)
884
+ log(` ${colors.cyan}${prefix} export${colors.reset} ${colors.dim}# Build dist/ for any static host (no Uniweb account)${colors.reset}`)
885
+ log('')
886
+ log(` ${colors.dim}See ${colors.reset}${colors.cyan}${prefix} <command> --help${colors.reset}${colors.dim} for command-specific options.${colors.reset}`)
887
+ log('')
818
888
 
819
889
  await showUpdateNotification()
820
890
  }
821
891
 
892
+ /**
893
+ * Print help for a specific command. Returns true if a dedicated help
894
+ * block exists for the command, false to signal "fall back to global
895
+ * help."
896
+ *
897
+ * Help text intentionally lives next to the dispatcher rather than in
898
+ * the per-command files because most help-seekers haven't run that
899
+ * command yet — keeping it here means `uniweb foo --help` prints
900
+ * without loading @uniweb/build or any project context.
901
+ */
902
+ function printCommandHelp(command) {
903
+ const blocks = {
904
+ deploy: `
905
+ ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Deploy a site${colors.reset}
906
+
907
+ ${colors.bright}Usage:${colors.reset}
908
+ uniweb deploy [options]
909
+
910
+ The host is determined by the resolved deploy.yml target. Defaults to
911
+ ${colors.cyan}uniweb${colors.reset} hosting (link-mode, edge JIT prerender) when no deploy.yml exists.
912
+
913
+ ${colors.bright}Hosts:${colors.reset}
914
+ uniweb Uniweb hosting (default; requires \`uniweb login\`)
915
+ cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
916
+ netlify Netlify (alias of cloudflare-pages adapter)
917
+ vercel Vercel (build-only — deploy via \`npx vercel\`)
918
+ github-pages GitHub Pages (build-only — push dist/ to gh-pages)
919
+ s3-cloudfront AWS S3 + CloudFront (uploads + invalidates via CLI)
920
+ generic-static Plain static-host build, no host-specific helpers
921
+
922
+ ${colors.bright}Options:${colors.reset}
923
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
924
+ --host <name> Override the resolved target's host (does not persist)
925
+ --host No value → interactive picker (TTY only)
926
+ --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
927
+ --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
928
+ --no-save Skip the auto-save of lastDeploy in deploy.yml
929
+ --local Internal: target the unicloud mock (see workspace root CLAUDE.md)
930
+ --non-interactive Fail with usage info instead of prompting
931
+
932
+ ${colors.bright}Auth:${colors.reset}
933
+ \`host: uniweb\` requires authentication. Run \`uniweb login\` first, set
934
+ \`UNIWEB_TOKEN=<bearer>\` env var, or use a static-host adapter that
935
+ doesn't need a Uniweb account. CI / agents / piped stdin auto-detect
936
+ non-interactive mode and bail with an actionable error instead of
937
+ hanging on a browser callback.
938
+
939
+ ${colors.bright}Examples:${colors.reset}
940
+ uniweb deploy # Default (host=uniweb)
941
+ uniweb deploy --dry-run # Print summary, no writes
942
+ uniweb deploy --host=cloudflare-pages # One-off override
943
+ uniweb deploy --target=preview # Pick named target from deploy.yml
944
+ `,
945
+ publish: `
946
+ ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a foundation to the catalog${colors.reset}
947
+
948
+ ${colors.bright}Usage:${colors.reset}
949
+ uniweb publish [@org/name] [options]
950
+
951
+ For site-bound foundations (one foundation, one site), use \`uniweb deploy\`
952
+ instead — it auto-publishes under a site-scoped slot, no naming ceremony.
953
+
954
+ ${colors.bright}Options:${colors.reset}
955
+ --catalog Confirm publish to the public catalog (required in CI)
956
+ --propagate Walk trusting sites' policy waves (default: silent)
957
+ --name <id> Foundation id (overrides package.json::uniweb.id)
958
+ --namespace <ns> Force org-scope namespace (overrides package.json)
959
+ --local Internal: publish to the unicloud mock (see workspace root CLAUDE.md)
960
+ --registry <url> Use a specific registry URL
961
+ --edit-access <p> "open" or "restricted" (default: restricted)
962
+ --dry-run Show what would be published without uploading
963
+ --non-interactive Fail with usage info instead of prompting
964
+ `,
965
+ create: `
966
+ ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
967
+
968
+ ${colors.bright}Usage:${colors.reset}
969
+ uniweb create [name] [options]
970
+
971
+ ${colors.bright}Options:${colors.reset}
972
+ --template <type> Project template (default: starter)
973
+ Built-in: starter, none, marketing
974
+ Local: ./path/to/template
975
+ npm: @scope/template-name
976
+ GitHub: github:user/repo or https://github.com/user/repo
977
+ --blank Create an empty workspace (grow with \`uniweb add\`)
978
+ --name <name> Project display name
979
+ --no-git Skip git repository initialization
980
+
981
+ ${colors.bright}Examples:${colors.reset}
982
+ uniweb create my-project # Foundation + site + starter content
983
+ uniweb create my-project --template marketing # Official template
984
+ uniweb create my-project --blank # Empty workspace
985
+ `,
986
+ dev: `
987
+ ${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
988
+
989
+ ${colors.bright}Usage:${colors.reset}
990
+ uniweb dev Start dev server for the (single) site
991
+ uniweb dev <site> Start dev server for a specific site
992
+ uniweb dev --site <name> Same, with explicit flag form
993
+
994
+ Thin wrapper around the package manager's workspace-filtered \`dev\`
995
+ script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
996
+ the single site automatically; for multi-site workspaces the first
997
+ site runs by default with a notice pointing at \`--site\` for explicit
998
+ selection.
999
+ `,
1000
+ build: `
1001
+ ${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
1002
+
1003
+ ${colors.bright}Usage:${colors.reset}
1004
+ uniweb build [options]
1005
+
1006
+ At workspace root, builds all foundations first, then all sites.
1007
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
1008
+
1009
+ ${colors.bright}Options:${colors.reset}
1010
+ --target <type> Build target (foundation, site) — auto-detected if not specified
1011
+ --prerender Force pre-rendering (overrides site.yml)
1012
+ --no-prerender Skip pre-rendering (overrides site.yml)
1013
+ --foundation-dir Path to foundation directory (for prerendering)
1014
+ --host <name> Apply host-specific postBuild (e.g., cloudflare-pages emits _redirects)
1015
+ --platform <name> (Deprecated alias for --host)
1016
+ `,
1017
+ add: `
1018
+ ${colors.cyan}${colors.bright}uniweb add${colors.reset} ${colors.dim}— Add a foundation, site, or extension${colors.reset}
1019
+
1020
+ ${colors.bright}Subcommands:${colors.reset}
1021
+ add project [name] Add a co-located foundation + site pair
1022
+ add foundation [name] Add a foundation (--from, --path, --project)
1023
+ add site [name] Add a site (--from, --foundation, --path, --project)
1024
+ add extension <name> Add an extension (--from, --site, --path)
1025
+ add section <name> Add a section type to a foundation (--foundation)
1026
+
1027
+ ${colors.bright}Common options:${colors.reset}
1028
+ --from <template> Source content from a template
1029
+ --path <dir> Override default folder location
1030
+ --foundation <name> Wire site/extension to this foundation (CI-friendly)
1031
+ --site <name> Wire extension to this site (CI-friendly)
1032
+ --non-interactive Fail with usage info instead of prompting
1033
+ `,
1034
+ export: `
1035
+ ${colors.cyan}${colors.bright}uniweb export${colors.reset} ${colors.dim}— Export a self-contained site for third-party hosting${colors.reset}
1036
+
1037
+ ${colors.bright}Usage:${colors.reset}
1038
+ uniweb export [options]
1039
+
1040
+ Builds dist/ and prints upload examples for common static hosts. No login,
1041
+ no deploy step — you push the artifact to your host of choice yourself.
1042
+ For Uniweb-hosted sites, use \`uniweb deploy\`.
1043
+
1044
+ ${colors.bright}Options:${colors.reset}
1045
+ --no-prerender Skip per-page prerendered HTML
1046
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
1047
+ `,
1048
+ doctor: `
1049
+ ${colors.cyan}${colors.bright}uniweb doctor${colors.reset} ${colors.dim}— Diagnose project configuration issues${colors.reset}
1050
+
1051
+ ${colors.bright}Usage:${colors.reset}
1052
+ uniweb doctor [options]
1053
+
1054
+ ${colors.bright}Options:${colors.reset}
1055
+ --fix Apply fixes for safely-fixable issues
1056
+ --fix <issue-id> Apply fix for a specific issue id only
1057
+ --non-interactive Fail with usage info instead of prompting
1058
+
1059
+ Exit code is 1 if errors are found (warnings only → exit 0).
1060
+ `,
1061
+ rename: `
1062
+ ${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rename a workspace package${colors.reset}
1063
+
1064
+ ${colors.bright}Usage:${colors.reset}
1065
+ uniweb rename foundation <old> <new>
1066
+
1067
+ Today supports renaming foundations only. Updates folder name, foundation
1068
+ package.json::name, every dependent site's site.yml::foundation, every
1069
+ dependent site's package.json::dependencies, pnpm-workspace.yaml, and
1070
+ package.json::workspaces. Transactional — bails on conflict before any
1071
+ filesystem mutation.
1072
+ `,
1073
+ login: `
1074
+ ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
1075
+
1076
+ ${colors.bright}Usage:${colors.reset}
1077
+ uniweb login [options]
1078
+
1079
+ Opens a browser to hub.uniweb.app for OAuth-style login, then captures
1080
+ the token via a loopback callback. Falls back to a paste-token prompt
1081
+ if the browser flow fails.
1082
+
1083
+ ${colors.bright}Options:${colors.reset}
1084
+ --backend <url> Override the auth backend (default: https://hub.uniweb.app)
1085
+
1086
+ In non-interactive mode (CI / no TTY / --non-interactive), this command
1087
+ errors out — set the \`UNIWEB_TOKEN\` env var instead, or run \`login\`
1088
+ once on a machine with a browser to seed ~/.uniweb/auth.json.
1089
+ `,
1090
+ invite: `
1091
+ ${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— Create a foundation invite for a client${colors.reset}
1092
+
1093
+ ${colors.bright}Usage:${colors.reset}
1094
+ uniweb invite <email> [options]
1095
+
1096
+ ${colors.bright}Options:${colors.reset}
1097
+ --uses <n> Max sites per invite (default: 1)
1098
+ --expires <days> Days until expiry (default: 30)
1099
+ --version <n> Major version to license (default: current)
1100
+ --list List invites for your foundation
1101
+ --revoke <id> Revoke an invite
1102
+ --resend <id> Resend an invite
1103
+ `,
1104
+ handoff: `
1105
+ ${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— Hand off a site to a client${colors.reset}
1106
+
1107
+ ${colors.bright}Usage:${colors.reset}
1108
+ uniweb handoff <email> [options]
1109
+
1110
+ ${colors.bright}Options:${colors.reset}
1111
+ --site <id> Site identifier (default: auto-generated)
1112
+ --web Show web-based handoff instructions instead
1113
+ `,
1114
+ template: `
1115
+ ${colors.cyan}${colors.bright}uniweb template${colors.reset} ${colors.dim}— Manage cloud templates${colors.reset}
1116
+
1117
+ ${colors.bright}Subcommands:${colors.reset}
1118
+ template publish Publish a site as a cloud template
1119
+
1120
+ ${colors.bright}Publish Options:${colors.reset}
1121
+ --name <name> Template registry name (overrides site.yml template: field)
1122
+ --title <title> Display title (overrides site.yml name: field)
1123
+ --description <t> Description
1124
+ --registry <url> Registry URL (default: http://localhost:4001)
1125
+ `,
1126
+ docs: `
1127
+ ${colors.cyan}${colors.bright}uniweb docs${colors.reset} ${colors.dim}— Generate component documentation${colors.reset}
1128
+
1129
+ ${colors.bright}Subcommands:${colors.reset}
1130
+ docs Generate COMPONENTS.md from foundation schema
1131
+ docs site Show site.yml configuration reference
1132
+ docs page Show page.yml configuration reference
1133
+ docs meta Show component meta.js reference
1134
+
1135
+ ${colors.bright}Options:${colors.reset}
1136
+ --output <file> Output filename (default: COMPONENTS.md)
1137
+ --from-source Read meta.js files directly instead of schema.json
1138
+ `,
1139
+ i18n: `
1140
+ ${colors.cyan}${colors.bright}uniweb i18n${colors.reset} ${colors.dim}— Internationalization workflow${colors.reset}
1141
+
1142
+ ${colors.bright}Subcommands:${colors.reset}
1143
+ i18n extract Extract translatable strings to manifest
1144
+ i18n sync Update manifest with content changes
1145
+ i18n status Show translation coverage per locale
1146
+ `,
1147
+ inspect: `
1148
+ ${colors.cyan}${colors.bright}uniweb inspect${colors.reset} ${colors.dim}— Inspect parsed content shape${colors.reset}
1149
+
1150
+ ${colors.bright}Usage:${colors.reset}
1151
+ uniweb inspect <path>
1152
+
1153
+ Prints the parsed content shape of a markdown file or folder — the
1154
+ { content, params, items, … } object that components actually receive.
1155
+ Useful for debugging "why isn't my section getting X?".
1156
+ `,
1157
+ update: `
1158
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Update the CLI itself, plus AGENTS.md when in a project${colors.reset}
1159
+
1160
+ ${colors.bright}Usage:${colors.reset}
1161
+ uniweb update Self-update + (in project) refresh AGENTS.md
1162
+ uniweb update --agents-only Only refresh AGENTS.md (skip self-update)
1163
+ uniweb update --no-agents Only self-update (skip AGENTS.md)
1164
+ uniweb update --yes Skip the confirmation prompts
1165
+
1166
+ ${colors.bright}What it does:${colors.reset}
1167
+ 1. Self-update the global install via npm / pnpm / yarn (auto-detected).
1168
+ In TTY, prompts before running. In CI / non-interactive, prints the
1169
+ command and exits without running it.
1170
+ 2. If the cwd resolves to a Uniweb project (root package.json declares
1171
+ \`uniweb\` as a dep), refreshes AGENTS.md from the CLI's bundled
1172
+ version. Outside a Uniweb project, this step is skipped — the
1173
+ command will not write AGENTS.md into unrelated directories.
1174
+
1175
+ ${colors.bright}Project-local installs:${colors.reset}
1176
+ When the running CLI is project-local (lives in node_modules), self-
1177
+ update is a no-op — the version is pinned by your project's
1178
+ package.json. The verb prints that explanation and proceeds with the
1179
+ AGENTS.md refresh path only.
1180
+ `,
1181
+ }
1182
+
1183
+ if (!blocks[command]) return false
1184
+ log(blocks[command])
1185
+ return true
1186
+ }
1187
+
822
1188
  function showHelp() {
823
1189
  log(`
824
1190
  ${colors.cyan}${colors.bright}Uniweb CLI${colors.reset} ${colors.dim}v${getCliVersion()}${colors.reset}
@@ -830,6 +1196,7 @@ ${colors.bright}Commands:${colors.reset}
830
1196
  create [name] Create a new project
831
1197
  add <type> [name] Add a foundation, site, or extension to a project
832
1198
  rename <type> Rename a workspace package (foundation today)
1199
+ dev Start a dev server for a site
833
1200
  build Build the current project
834
1201
  deploy Deploy a site to Uniweb hosting
835
1202
  export Export a self-contained site for third-party hosting
@@ -896,21 +1263,34 @@ ${colors.bright}Template Options:${colors.reset}
896
1263
  --registry <url> Registry URL (default: http://localhost:4001)
897
1264
 
898
1265
  ${colors.bright}Deploy Options:${colors.reset}
1266
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1267
+ --host <name> Override the resolved target's host (does not persist).
1268
+ Without a value, opens an interactive picker (TTY only).
1269
+ Hosts: uniweb, cloudflare-pages, netlify, vercel,
1270
+ github-pages, s3-cloudfront, generic-static.
899
1271
  --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
900
1272
  --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
1273
+ --no-save Skip the auto-save of lastDeploy in deploy.yml
1274
+
1275
+ ${colors.bright}Dev Options:${colors.reset}
1276
+ <site> Site name to run (positional)
1277
+ --site <name> Site name to run (explicit form)
901
1278
 
902
1279
  ${colors.bright}Export Options:${colors.reset}
903
1280
  --no-prerender Skip per-page prerendered HTML
1281
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
904
1282
 
905
1283
  ${colors.bright}Build Options:${colors.reset}
906
- --target <type> Build target (foundation, site) - auto-detected if not specified
1284
+ --target <type> Build target (foundation, site) auto-detected if not specified
907
1285
  --prerender Force pre-rendering (overrides site.yml)
908
1286
  --no-prerender Skip pre-rendering (overrides site.yml)
909
1287
  --foundation-dir Path to foundation directory (for prerendering)
910
- --platform <name> Deployment platform (e.g., vercel) for platform-specific output
1288
+ --host <name> Apply host-specific postBuild (cloudflare-pages, s3-cloudfront, …)
1289
+ --platform <name> (Deprecated alias for --host)
911
1290
 
912
1291
  At workspace root, builds all foundations first, then all sites.
913
- Pre-rendering is enabled by default when build.prerender: true in site.yml
1292
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
1293
+ See \`uniweb <command> --help\` for command-specific detail and examples.
914
1294
 
915
1295
  ${colors.bright}Docs Subcommands:${colors.reset}
916
1296
  docs Generate COMPONENTS.md from foundation schema
package/src/utils/auth.js CHANGED
@@ -153,17 +153,45 @@ export function isExpired(auth) {
153
153
  * Ensure the user is authenticated. If not, prompt inline login.
154
154
  * Returns the auth token on success, exits the process on cancel.
155
155
  *
156
+ * In non-interactive mode (CI, no TTY, or --non-interactive in args),
157
+ * bails with an actionable error instead of opening a browser. The browser
158
+ * login flow waits 120 seconds for a callback that can never arrive without
159
+ * a user, then drops to a token-paste prompt that pipes can't answer —
160
+ * silently burning two minutes per invocation. CI / agent / piped callers
161
+ * must set `UNIWEB_TOKEN`, run `uniweb login` interactively first, or use
162
+ * `--local` for the unicloud mock (see workspace root CLAUDE.md).
163
+ *
156
164
  * @param {Object} options
157
165
  * @param {string} options.command - The command that needs auth (for messaging)
166
+ * @param {string[]} [options.args] - Argv slice; checked for --non-interactive
158
167
  * @returns {Promise<string>} Bearer token
159
168
  */
160
- export async function ensureAuth({ command = 'This command' } = {}) {
169
+ export async function ensureAuth({ command = 'This command', args = [] } = {}) {
170
+ // Honor explicit token from env — useful for CI and agents.
171
+ if (process.env.UNIWEB_TOKEN) {
172
+ return process.env.UNIWEB_TOKEN
173
+ }
174
+
161
175
  const auth = await readAuth()
162
176
 
163
177
  if (auth?.token && !isExpired(auth)) {
164
178
  return auth.token
165
179
  }
166
180
 
181
+ // Non-interactive bail: don't open a browser, don't wait 120s, don't
182
+ // prompt for a token paste. Print an actionable error and exit.
183
+ const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
184
+ if (isNonInteractive(args)) {
185
+ const prefix = getCliPrefix()
186
+ const reason = auth && isExpired(auth) ? 'Session expired.' : 'Not logged in.'
187
+ console.error(`\x1b[31m✗\x1b[0m ${reason} ${command} requires a Uniweb account, and the CLI is in non-interactive mode (CI / no TTY / --non-interactive).`)
188
+ console.error(` Options:`)
189
+ console.error(` • Run \`${prefix} login\` interactively first, then re-run.`)
190
+ console.error(` • Set the \`UNIWEB_TOKEN\` env var to a bearer token.`)
191
+ console.error(` • Use \`--local\` to target the unicloud mock (internal testing only — see workspace root CLAUDE.md).`)
192
+ process.exit(1)
193
+ }
194
+
167
195
  // Need to log in — delegate to the login command
168
196
  if (auth && isExpired(auth)) {
169
197
  console.log(`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`)
@@ -157,9 +157,17 @@ export async function writeRootPackageJson(rootDir, pkg) {
157
157
  }
158
158
 
159
159
  /**
160
- * Compute root scripts based on discovered sites
160
+ * Compute root scripts based on discovered sites.
161
+ *
162
+ * `dev` and `build` route through the uniweb CLI verb (PM-agnostic — they
163
+ * resolve `uniweb` from the project's local node_modules/.bin, so `pnpm
164
+ * dev` and `npm run dev` both work without locking the scripts to one PM
165
+ * at create-time). `preview` stays on a PM-specific filter because there
166
+ * isn't a `uniweb preview` verb yet (the site's own `vite preview` is what
167
+ * runs); switch it over when one ships.
168
+ *
161
169
  * @param {Array<{name: string, path: string}>} sites - Discovered sites
162
- * @param {'pnpm' | 'npm'} [pm='pnpm'] - Package manager
170
+ * @param {'pnpm' | 'npm'} [pm='pnpm'] - Package manager (used only for preview)
163
171
  * @returns {Object} Scripts object for package.json
164
172
  */
165
173
  export function computeRootScripts(sites, pm = 'pnpm') {
@@ -172,16 +180,17 @@ export function computeRootScripts(sites, pm = 'pnpm') {
172
180
  }
173
181
 
174
182
  if (sites.length === 1) {
175
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
183
+ scripts.dev = 'uniweb dev'
176
184
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
177
185
  } else {
178
- // First site gets unqualified dev/preview
179
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
186
+ // First site gets unqualified dev/preview (matches `uniweb dev`'s
187
+ // default-to-first-site behavior).
188
+ scripts.dev = 'uniweb dev'
180
189
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
181
190
 
182
191
  // Subsequent sites get qualified dev:{name}/preview:{name}
183
192
  for (let i = 1; i < sites.length; i++) {
184
- scripts[`dev:${sites[i].name}`] = filterCmd(pm, sites[i].name, 'dev')
193
+ scripts[`dev:${sites[i].name}`] = `uniweb dev ${sites[i].name}`
185
194
  scripts[`preview:${sites[i].name}`] = filterCmd(pm, sites[i].name, 'preview')
186
195
  }
187
196
  }
@@ -52,17 +52,55 @@ function writeState(state) {
52
52
 
53
53
  /**
54
54
  * Print update notification to stderr (doesn't interfere with piped output).
55
+ * `tone` controls the lead-in: 'soft' (default — trailing notice for finished
56
+ * commands) vs 'eager' (leading notice for staleness-sensitive commands like
57
+ * `create`, where the user is about to scaffold files from CLI-bundled
58
+ * templates and a stale CLI means stale starter content).
55
59
  */
56
- function printNotification(current, latest) {
60
+ function printNotification(current, latest, tone = 'soft') {
57
61
  const yellow = '\x1b[33m'
58
62
  const cyan = '\x1b[36m'
59
63
  const dim = '\x1b[2m'
60
64
  const reset = '\x1b[0m'
61
65
  console.error('')
62
- console.error(`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`)
63
- console.error(`${dim}Run${reset} npm i -g uniweb ${dim}to update${reset}`)
66
+ if (tone === 'eager') {
67
+ console.error(`${yellow}Heads up:${reset} this CLI is ${dim}${current}${reset}; latest is ${cyan}${latest}${reset}.`)
68
+ console.error(`${dim}Templates ship with the CLI — consider updating first:${reset} npm i -g uniweb`)
69
+ console.error(`${dim}Or run a one-shot fresh:${reset} npx uniweb@latest <command>`)
70
+ } else {
71
+ console.error(`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`)
72
+ console.error(`${dim}Run${reset} npm i -g uniweb ${dim}to update${reset}`)
73
+ }
64
74
  }
65
75
 
76
+ /**
77
+ * Synchronously read the cache and print a notification if a newer
78
+ * version is known. No network fetch — only reads what `startUpdateCheck`
79
+ * has previously cached. Returns true if a notification was printed.
80
+ *
81
+ * Two call sites today, with different tone needs:
82
+ * - `create` (tone='eager'): loud leading notice — templates ship with
83
+ * the CLI, the user is about to scaffold files, this matters.
84
+ * - `--version` / `-v` (tone='soft'): brief trailing notice — the user
85
+ * was already asking about version, mention staleness while we're
86
+ * here. Goes to stderr so scripts capturing stdout aren't affected.
87
+ *
88
+ * @param {string} currentVersion
89
+ * @param {'eager'|'soft'} [tone='eager']
90
+ * @returns {boolean} true if a notification was printed
91
+ */
92
+ export function maybeNotifyFromCache(currentVersion, tone = 'eager') {
93
+ const state = readState()
94
+ if (!state.latestVersion) return false
95
+ if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
96
+ printNotification(currentVersion, state.latestVersion, tone)
97
+ return true
98
+ }
99
+
100
+ // Old name preserved as alias — `create` calls it without a tone arg
101
+ // and gets the eager default. Keeps that call site unchanged.
102
+ export const maybeEagerNotification = maybeNotifyFromCache
103
+
66
104
  /**
67
105
  * Start a non-blocking update check.
68
106
  *