uniweb 0.12.10 → 0.12.11

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.11",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,8 +42,8 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/runtime": "0.8.13",
45
- "@uniweb/kit": "0.9.11",
46
- "@uniweb/core": "0.7.11"
45
+ "@uniweb/core": "0.7.11",
46
+ "@uniweb/kit": "0.9.11"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "0.14.2",
@@ -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,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
@@ -199,12 +199,16 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
199
199
 
200
200
  onProgress?.('Setting up workspace...')
201
201
 
202
- // 1. Scaffold workspace
202
+ // 1. Scaffold workspace.
203
+ // dev/build go through `uniweb` verbs so the scripts stay PM-agnostic
204
+ // (the verb resolves the right PM at runtime instead of locking the
205
+ // root scripts to whichever PM ran `npx uniweb create`). preview stays
206
+ // PM-filtered until a `uniweb preview` verb exists.
203
207
  await scaffoldWorkspace(projectDir, {
204
208
  projectName,
205
209
  workspaceGlobs: ['site', 'src'],
206
210
  scripts: {
207
- dev: filterCmd(pm, 'site', 'dev'),
211
+ dev: 'uniweb dev',
208
212
  build: 'uniweb build',
209
213
  preview: filterCmd(pm, 'site', 'preview'),
210
214
  },
@@ -286,17 +290,19 @@ async function createFromContentTemplate(projectDir, projectName, metadata, temp
286
290
  const scripts = {
287
291
  build: 'uniweb build',
288
292
  }
293
+ // dev goes through `uniweb` (PM-agnostic; see computeRootScripts).
294
+ // preview stays PM-filtered until a `uniweb preview` verb exists.
289
295
  if (sites.length === 1) {
290
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
296
+ scripts.dev = 'uniweb dev'
291
297
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
292
298
  } else {
293
299
  for (const s of sites) {
294
- scripts[`dev:${s.name}`] = filterCmd(pm, s.name, 'dev')
300
+ scripts[`dev:${s.name}`] = `uniweb dev ${s.name}`
295
301
  scripts[`preview:${s.name}`] = filterCmd(pm, s.name, 'preview')
296
302
  }
297
303
  // First site gets unqualified aliases
298
304
  if (sites.length > 0) {
299
- scripts.dev = filterCmd(pm, sites[0].name, 'dev')
305
+ scripts.dev = 'uniweb dev'
300
306
  scripts.preview = filterCmd(pm, sites[0].name, 'preview')
301
307
  }
302
308
  }
@@ -452,12 +458,26 @@ async function main() {
452
458
  // Commands that need @uniweb/build will get a helpful error via importProjectCommand().
453
459
  }
454
460
 
455
- // Start non-blocking update check for global installs
461
+ // Start non-blocking update check for global installs.
462
+ //
463
+ // Two surfaces:
464
+ // - showUpdateNotification (soft, trailing): printed at command end for
465
+ // any verb. Doesn't interrupt the user's workflow.
466
+ // - eager (loud, leading): printed BEFORE staleness-sensitive verbs do
467
+ // their work. Today: only `create` (templates ship with the CLI, so
468
+ // a stale CLI scaffolds stale starter content; the user needs to know
469
+ // before files hit disk). Other verbs are insensitive — `deploy` etc.
470
+ // are project-bound (delegated to local node_modules), and the
471
+ // local-vs-global mismatch warning in delegateToLocal already covers
472
+ // that case.
456
473
  let showUpdateNotification = () => {}
457
474
  if (global) {
458
475
  try {
459
- const { startUpdateCheck } = await import('./utils/update-check.js')
476
+ const { startUpdateCheck, maybeEagerNotification } = await import('./utils/update-check.js')
460
477
  showUpdateNotification = startUpdateCheck(getCliVersion())
478
+ if (command === 'create') {
479
+ maybeEagerNotification(getCliVersion())
480
+ }
461
481
  } catch {
462
482
  // Update check is optional — don't fail if the module is missing
463
483
  }
@@ -470,6 +490,23 @@ async function main() {
470
490
  return
471
491
  }
472
492
 
493
+ // Per-command --help: short-circuit BEFORE the command's side effects run.
494
+ // Critical for `deploy --help` (used to open a browser to production for
495
+ // login because deploy.js doesn't parse --help and ensureAuth ran first).
496
+ // Falls back to the global help when a command has no dedicated block.
497
+ if (args.slice(1).some(a => a === '--help' || a === '-h')) {
498
+ const printed = printCommandHelp(command)
499
+ if (printed) {
500
+ await showUpdateNotification()
501
+ return
502
+ }
503
+ // No dedicated block — show global help as a useful fallback rather
504
+ // than executing the command (which often has side effects).
505
+ showHelp()
506
+ await showUpdateNotification()
507
+ return
508
+ }
509
+
473
510
  // Handle build command (dynamic import — depends on @uniweb/build)
474
511
  if (command === 'build') {
475
512
  const { build } = await importProjectCommand('./commands/build.js')
@@ -478,6 +515,16 @@ async function main() {
478
515
  return
479
516
  }
480
517
 
518
+ // Handle dev command — thin wrapper that shells to the package manager's
519
+ // workspace-filtered `dev` script (mirrors what `uniweb create` writes
520
+ // into the root package.json::scripts.dev). Lazy import keeps startup
521
+ // fast when the user is not running dev.
522
+ if (command === 'dev') {
523
+ const { dev } = await import('./commands/dev.js')
524
+ await dev(args.slice(1))
525
+ return
526
+ }
527
+
481
528
  // Handle docs command (dynamic import — depends on @uniweb/build)
482
529
  if (command === 'docs') {
483
530
  const { docs } = await importProjectCommand('./commands/docs.js')
@@ -807,18 +854,307 @@ async function main() {
807
854
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
808
855
  log(` ${colors.cyan}${prefix} add project${colors.reset}`)
809
856
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
810
- log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
857
+ log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
811
858
  } else {
812
859
  log(`Next steps:\n`)
813
860
  log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
814
861
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
815
- log(` ${colors.cyan}${runCmd(pm, 'dev')}${colors.reset}`)
862
+ log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
816
863
  }
817
864
  log('')
865
+ log(`When ready to ship:\n`)
866
+ log(` ${colors.cyan}${prefix} deploy${colors.reset} ${colors.dim}# Uniweb hosting (default; uniweb login first)${colors.reset}`)
867
+ log(` ${colors.cyan}${prefix} deploy --host=<adapter>${colors.reset} ${colors.dim}# cloudflare-pages, netlify, vercel, github-pages, s3-cloudfront${colors.reset}`)
868
+ log(` ${colors.cyan}${prefix} export${colors.reset} ${colors.dim}# Build dist/ for any static host (no Uniweb account)${colors.reset}`)
869
+ log('')
870
+ log(` ${colors.dim}See ${colors.reset}${colors.cyan}${prefix} <command> --help${colors.reset}${colors.dim} for command-specific options.${colors.reset}`)
871
+ log('')
818
872
 
819
873
  await showUpdateNotification()
820
874
  }
821
875
 
876
+ /**
877
+ * Print help for a specific command. Returns true if a dedicated help
878
+ * block exists for the command, false to signal "fall back to global
879
+ * help."
880
+ *
881
+ * Help text intentionally lives next to the dispatcher rather than in
882
+ * the per-command files because most help-seekers haven't run that
883
+ * command yet — keeping it here means `uniweb foo --help` prints
884
+ * without loading @uniweb/build or any project context.
885
+ */
886
+ function printCommandHelp(command) {
887
+ const blocks = {
888
+ deploy: `
889
+ ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Deploy a site${colors.reset}
890
+
891
+ ${colors.bright}Usage:${colors.reset}
892
+ uniweb deploy [options]
893
+
894
+ The host is determined by the resolved deploy.yml target. Defaults to
895
+ ${colors.cyan}uniweb${colors.reset} hosting (link-mode, edge JIT prerender) when no deploy.yml exists.
896
+
897
+ ${colors.bright}Hosts:${colors.reset}
898
+ uniweb Uniweb hosting (default; requires \`uniweb login\`)
899
+ cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
900
+ netlify Netlify (alias of cloudflare-pages adapter)
901
+ vercel Vercel (build-only — deploy via \`npx vercel\`)
902
+ github-pages GitHub Pages (build-only — push dist/ to gh-pages)
903
+ s3-cloudfront AWS S3 + CloudFront (uploads + invalidates via CLI)
904
+ generic-static Plain static-host build, no host-specific helpers
905
+
906
+ ${colors.bright}Options:${colors.reset}
907
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
908
+ --host <name> Override the resolved target's host (does not persist)
909
+ --host No value → interactive picker (TTY only)
910
+ --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
911
+ --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
912
+ --no-save Skip the auto-save of lastDeploy in deploy.yml
913
+ --local Internal: target the unicloud mock (see workspace root CLAUDE.md)
914
+ --non-interactive Fail with usage info instead of prompting
915
+
916
+ ${colors.bright}Auth:${colors.reset}
917
+ \`host: uniweb\` requires authentication. Run \`uniweb login\` first, set
918
+ \`UNIWEB_TOKEN=<bearer>\` env var, or use a static-host adapter that
919
+ doesn't need a Uniweb account. CI / agents / piped stdin auto-detect
920
+ non-interactive mode and bail with an actionable error instead of
921
+ hanging on a browser callback.
922
+
923
+ ${colors.bright}Examples:${colors.reset}
924
+ uniweb deploy # Default (host=uniweb)
925
+ uniweb deploy --dry-run # Print summary, no writes
926
+ uniweb deploy --host=cloudflare-pages # One-off override
927
+ uniweb deploy --target=preview # Pick named target from deploy.yml
928
+ `,
929
+ publish: `
930
+ ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a foundation to the catalog${colors.reset}
931
+
932
+ ${colors.bright}Usage:${colors.reset}
933
+ uniweb publish [@org/name] [options]
934
+
935
+ For site-bound foundations (one foundation, one site), use \`uniweb deploy\`
936
+ instead — it auto-publishes under a site-scoped slot, no naming ceremony.
937
+
938
+ ${colors.bright}Options:${colors.reset}
939
+ --catalog Confirm publish to the public catalog (required in CI)
940
+ --propagate Walk trusting sites' policy waves (default: silent)
941
+ --name <id> Foundation id (overrides package.json::uniweb.id)
942
+ --namespace <ns> Force org-scope namespace (overrides package.json)
943
+ --local Internal: publish to the unicloud mock (see workspace root CLAUDE.md)
944
+ --registry <url> Use a specific registry URL
945
+ --edit-access <p> "open" or "restricted" (default: restricted)
946
+ --dry-run Show what would be published without uploading
947
+ --non-interactive Fail with usage info instead of prompting
948
+ `,
949
+ create: `
950
+ ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
951
+
952
+ ${colors.bright}Usage:${colors.reset}
953
+ uniweb create [name] [options]
954
+
955
+ ${colors.bright}Options:${colors.reset}
956
+ --template <type> Project template (default: starter)
957
+ Built-in: starter, none, marketing
958
+ Local: ./path/to/template
959
+ npm: @scope/template-name
960
+ GitHub: github:user/repo or https://github.com/user/repo
961
+ --blank Create an empty workspace (grow with \`uniweb add\`)
962
+ --name <name> Project display name
963
+ --no-git Skip git repository initialization
964
+
965
+ ${colors.bright}Examples:${colors.reset}
966
+ uniweb create my-project # Foundation + site + starter content
967
+ uniweb create my-project --template marketing # Official template
968
+ uniweb create my-project --blank # Empty workspace
969
+ `,
970
+ dev: `
971
+ ${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
972
+
973
+ ${colors.bright}Usage:${colors.reset}
974
+ uniweb dev Start dev server for the (single) site
975
+ uniweb dev <site> Start dev server for a specific site
976
+ uniweb dev --site <name> Same, with explicit flag form
977
+
978
+ Thin wrapper around the package manager's workspace-filtered \`dev\`
979
+ script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
980
+ the single site automatically; for multi-site workspaces the first
981
+ site runs by default with a notice pointing at \`--site\` for explicit
982
+ selection.
983
+ `,
984
+ build: `
985
+ ${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
986
+
987
+ ${colors.bright}Usage:${colors.reset}
988
+ uniweb build [options]
989
+
990
+ At workspace root, builds all foundations first, then all sites.
991
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
992
+
993
+ ${colors.bright}Options:${colors.reset}
994
+ --target <type> Build target (foundation, site) — auto-detected if not specified
995
+ --prerender Force pre-rendering (overrides site.yml)
996
+ --no-prerender Skip pre-rendering (overrides site.yml)
997
+ --foundation-dir Path to foundation directory (for prerendering)
998
+ --host <name> Apply host-specific postBuild (e.g., cloudflare-pages emits _redirects)
999
+ --platform <name> (Deprecated alias for --host)
1000
+ `,
1001
+ add: `
1002
+ ${colors.cyan}${colors.bright}uniweb add${colors.reset} ${colors.dim}— Add a foundation, site, or extension${colors.reset}
1003
+
1004
+ ${colors.bright}Subcommands:${colors.reset}
1005
+ add project [name] Add a co-located foundation + site pair
1006
+ add foundation [name] Add a foundation (--from, --path, --project)
1007
+ add site [name] Add a site (--from, --foundation, --path, --project)
1008
+ add extension <name> Add an extension (--from, --site, --path)
1009
+ add section <name> Add a section type to a foundation (--foundation)
1010
+
1011
+ ${colors.bright}Common options:${colors.reset}
1012
+ --from <template> Source content from a template
1013
+ --path <dir> Override default folder location
1014
+ --foundation <name> Wire site/extension to this foundation (CI-friendly)
1015
+ --site <name> Wire extension to this site (CI-friendly)
1016
+ --non-interactive Fail with usage info instead of prompting
1017
+ `,
1018
+ export: `
1019
+ ${colors.cyan}${colors.bright}uniweb export${colors.reset} ${colors.dim}— Export a self-contained site for third-party hosting${colors.reset}
1020
+
1021
+ ${colors.bright}Usage:${colors.reset}
1022
+ uniweb export [options]
1023
+
1024
+ Builds dist/ and prints upload examples for common static hosts. No login,
1025
+ no deploy step — you push the artifact to your host of choice yourself.
1026
+ For Uniweb-hosted sites, use \`uniweb deploy\`.
1027
+
1028
+ ${colors.bright}Options:${colors.reset}
1029
+ --no-prerender Skip per-page prerendered HTML
1030
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
1031
+ `,
1032
+ doctor: `
1033
+ ${colors.cyan}${colors.bright}uniweb doctor${colors.reset} ${colors.dim}— Diagnose project configuration issues${colors.reset}
1034
+
1035
+ ${colors.bright}Usage:${colors.reset}
1036
+ uniweb doctor [options]
1037
+
1038
+ ${colors.bright}Options:${colors.reset}
1039
+ --fix Apply fixes for safely-fixable issues
1040
+ --fix <issue-id> Apply fix for a specific issue id only
1041
+ --non-interactive Fail with usage info instead of prompting
1042
+
1043
+ Exit code is 1 if errors are found (warnings only → exit 0).
1044
+ `,
1045
+ rename: `
1046
+ ${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rename a workspace package${colors.reset}
1047
+
1048
+ ${colors.bright}Usage:${colors.reset}
1049
+ uniweb rename foundation <old> <new>
1050
+
1051
+ Today supports renaming foundations only. Updates folder name, foundation
1052
+ package.json::name, every dependent site's site.yml::foundation, every
1053
+ dependent site's package.json::dependencies, pnpm-workspace.yaml, and
1054
+ package.json::workspaces. Transactional — bails on conflict before any
1055
+ filesystem mutation.
1056
+ `,
1057
+ login: `
1058
+ ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
1059
+
1060
+ ${colors.bright}Usage:${colors.reset}
1061
+ uniweb login [options]
1062
+
1063
+ Opens a browser to hub.uniweb.app for OAuth-style login, then captures
1064
+ the token via a loopback callback. Falls back to a paste-token prompt
1065
+ if the browser flow fails.
1066
+
1067
+ ${colors.bright}Options:${colors.reset}
1068
+ --backend <url> Override the auth backend (default: https://hub.uniweb.app)
1069
+
1070
+ In non-interactive mode (CI / no TTY / --non-interactive), this command
1071
+ errors out — set the \`UNIWEB_TOKEN\` env var instead, or run \`login\`
1072
+ once on a machine with a browser to seed ~/.uniweb/auth.json.
1073
+ `,
1074
+ invite: `
1075
+ ${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— Create a foundation invite for a client${colors.reset}
1076
+
1077
+ ${colors.bright}Usage:${colors.reset}
1078
+ uniweb invite <email> [options]
1079
+
1080
+ ${colors.bright}Options:${colors.reset}
1081
+ --uses <n> Max sites per invite (default: 1)
1082
+ --expires <days> Days until expiry (default: 30)
1083
+ --version <n> Major version to license (default: current)
1084
+ --list List invites for your foundation
1085
+ --revoke <id> Revoke an invite
1086
+ --resend <id> Resend an invite
1087
+ `,
1088
+ handoff: `
1089
+ ${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— Hand off a site to a client${colors.reset}
1090
+
1091
+ ${colors.bright}Usage:${colors.reset}
1092
+ uniweb handoff <email> [options]
1093
+
1094
+ ${colors.bright}Options:${colors.reset}
1095
+ --site <id> Site identifier (default: auto-generated)
1096
+ --web Show web-based handoff instructions instead
1097
+ `,
1098
+ template: `
1099
+ ${colors.cyan}${colors.bright}uniweb template${colors.reset} ${colors.dim}— Manage cloud templates${colors.reset}
1100
+
1101
+ ${colors.bright}Subcommands:${colors.reset}
1102
+ template publish Publish a site as a cloud template
1103
+
1104
+ ${colors.bright}Publish Options:${colors.reset}
1105
+ --name <name> Template registry name (overrides site.yml template: field)
1106
+ --title <title> Display title (overrides site.yml name: field)
1107
+ --description <t> Description
1108
+ --registry <url> Registry URL (default: http://localhost:4001)
1109
+ `,
1110
+ docs: `
1111
+ ${colors.cyan}${colors.bright}uniweb docs${colors.reset} ${colors.dim}— Generate component documentation${colors.reset}
1112
+
1113
+ ${colors.bright}Subcommands:${colors.reset}
1114
+ docs Generate COMPONENTS.md from foundation schema
1115
+ docs site Show site.yml configuration reference
1116
+ docs page Show page.yml configuration reference
1117
+ docs meta Show component meta.js reference
1118
+
1119
+ ${colors.bright}Options:${colors.reset}
1120
+ --output <file> Output filename (default: COMPONENTS.md)
1121
+ --from-source Read meta.js files directly instead of schema.json
1122
+ `,
1123
+ i18n: `
1124
+ ${colors.cyan}${colors.bright}uniweb i18n${colors.reset} ${colors.dim}— Internationalization workflow${colors.reset}
1125
+
1126
+ ${colors.bright}Subcommands:${colors.reset}
1127
+ i18n extract Extract translatable strings to manifest
1128
+ i18n sync Update manifest with content changes
1129
+ i18n status Show translation coverage per locale
1130
+ `,
1131
+ inspect: `
1132
+ ${colors.cyan}${colors.bright}uniweb inspect${colors.reset} ${colors.dim}— Inspect parsed content shape${colors.reset}
1133
+
1134
+ ${colors.bright}Usage:${colors.reset}
1135
+ uniweb inspect <path>
1136
+
1137
+ Prints the parsed content shape of a markdown file or folder — the
1138
+ { content, params, items, … } object that components actually receive.
1139
+ Useful for debugging "why isn't my section getting X?".
1140
+ `,
1141
+ update: `
1142
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Update AGENTS.md to match installed CLI version${colors.reset}
1143
+
1144
+ ${colors.bright}Usage:${colors.reset}
1145
+ uniweb update
1146
+
1147
+ Refreshes the project's AGENTS.md from the CLI's bundled version. Run
1148
+ after upgrading the \`uniweb\` package to pick up new content authoring
1149
+ patterns and platform documentation.
1150
+ `,
1151
+ }
1152
+
1153
+ if (!blocks[command]) return false
1154
+ log(blocks[command])
1155
+ return true
1156
+ }
1157
+
822
1158
  function showHelp() {
823
1159
  log(`
824
1160
  ${colors.cyan}${colors.bright}Uniweb CLI${colors.reset} ${colors.dim}v${getCliVersion()}${colors.reset}
@@ -830,6 +1166,7 @@ ${colors.bright}Commands:${colors.reset}
830
1166
  create [name] Create a new project
831
1167
  add <type> [name] Add a foundation, site, or extension to a project
832
1168
  rename <type> Rename a workspace package (foundation today)
1169
+ dev Start a dev server for a site
833
1170
  build Build the current project
834
1171
  deploy Deploy a site to Uniweb hosting
835
1172
  export Export a self-contained site for third-party hosting
@@ -896,21 +1233,34 @@ ${colors.bright}Template Options:${colors.reset}
896
1233
  --registry <url> Registry URL (default: http://localhost:4001)
897
1234
 
898
1235
  ${colors.bright}Deploy Options:${colors.reset}
1236
+ --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1237
+ --host <name> Override the resolved target's host (does not persist).
1238
+ Without a value, opens an interactive picker (TTY only).
1239
+ Hosts: uniweb, cloudflare-pages, netlify, vercel,
1240
+ github-pages, s3-cloudfront, generic-static.
899
1241
  --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
900
1242
  --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
1243
+ --no-save Skip the auto-save of lastDeploy in deploy.yml
1244
+
1245
+ ${colors.bright}Dev Options:${colors.reset}
1246
+ <site> Site name to run (positional)
1247
+ --site <name> Site name to run (explicit form)
901
1248
 
902
1249
  ${colors.bright}Export Options:${colors.reset}
903
1250
  --no-prerender Skip per-page prerendered HTML
1251
+ --host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
904
1252
 
905
1253
  ${colors.bright}Build Options:${colors.reset}
906
- --target <type> Build target (foundation, site) - auto-detected if not specified
1254
+ --target <type> Build target (foundation, site) auto-detected if not specified
907
1255
  --prerender Force pre-rendering (overrides site.yml)
908
1256
  --no-prerender Skip pre-rendering (overrides site.yml)
909
1257
  --foundation-dir Path to foundation directory (for prerendering)
910
- --platform <name> Deployment platform (e.g., vercel) for platform-specific output
1258
+ --host <name> Apply host-specific postBuild (cloudflare-pages, s3-cloudfront, …)
1259
+ --platform <name> (Deprecated alias for --host)
911
1260
 
912
1261
  At workspace root, builds all foundations first, then all sites.
913
- Pre-rendering is enabled by default when build.prerender: true in site.yml
1262
+ Pre-rendering is enabled by default when build.prerender: true in site.yml.
1263
+ See \`uniweb <command> --help\` for command-specific detail and examples.
914
1264
 
915
1265
  ${colors.bright}Docs Subcommands:${colors.reset}
916
1266
  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,15 +52,46 @@ 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
+ }
74
+ }
75
+
76
+ /**
77
+ * Synchronously read the cache and print an eager 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
+ * Use this for staleness-sensitive verbs (`create`) BEFORE the verb does
82
+ * its work, so the user sees the warning before any files are written
83
+ * from CLI-bundled templates. For other verbs, the trailing soft
84
+ * notification from startUpdateCheck() is sufficient.
85
+ *
86
+ * @param {string} currentVersion
87
+ * @returns {boolean} true if a notification was printed
88
+ */
89
+ export function maybeEagerNotification(currentVersion) {
90
+ const state = readState()
91
+ if (!state.latestVersion) return false
92
+ if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
93
+ printNotification(currentVersion, state.latestVersion, 'eager')
94
+ return true
64
95
  }
65
96
 
66
97
  /**