uniweb 0.12.9 → 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/README.md CHANGED
@@ -310,14 +310,22 @@ Start with local files deployed anywhere. The same foundation works across all t
310
310
 
311
311
  ## Deployment
312
312
 
313
- A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. That opens up deployment options other frameworks can't express:
313
+ A Uniweb project produces two artifacts — a **site** (content) and a **foundation** (code) — and they don't have to ship together. Two top-level modes:
314
314
 
315
- - **Bundled mode** — site and foundation built into one self-contained `dist/`, deployed to any static host.
316
- - **Linked mode** — the foundation lives in any host and the site in any other host; different sites can dynamically link with the same foundation. Update the foundation, every site picks it up — no site rebuilds.
315
+ - **Standalone mode** — site and foundation built into one self-contained `dist/`, deployed to any static host.
316
+ - **Linked mode** — the foundation is a separate file the site loads at runtime, with two flavours:
317
+ - **Site-bound** — the foundation belongs to one site and rides with it (`foundation: ~self/<name>@<version>` in `site.yml`).
318
+ - **Cataloged** — the foundation is a catalog product, published once and licensed to consuming sites (`foundation: '@<org>/<name>@<version>'`).
317
319
 
318
- Two verbs handle it: `uniweb publish` sends a foundation to a registry, `uniweb deploy` sends a site to a host. Most projects start bundled (one command, one destination) and grow into linked mode by changing one line in `site.yml`. Mix providers freely foundation on GitHub Pages, site on Vercel; or use Uniweb's registry + hosting for propagation, gated rollouts, and edge SSR.
320
+ `uniweb publish` ships a cataloged foundation; `uniweb deploy` ships a site (and, for site-bound, the foundation along with it). Most projects start standalone or site-bound and grow into cataloged when a foundation needs to serve more than one site.
319
321
 
320
- **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** the full menu: bundled vs linked, the two-verb model, one-foundation-many-sites, optimized hosting on the Uniweb platform, and recipes for other hosting services.
322
+ Where can you deploy?
323
+
324
+ - **Free static hosts** — Vercel, Cloudflare Pages, Netlify, GitHub Pages — work great when you have a site to publish. Built-in adapters: `vercel`, `cloudflare-pages`, `netlify`, `github-pages`. Lifecycle is Git-driven: connect your repo, the host runs `uniweb build` on each push, serves `dist/`. The framework auto-detects the CI host and emits the right helper files.
325
+ - **AWS S3 + CloudFront** — `uniweb deploy --host=s3-cloudfront` builds, syncs, and invalidates in one command.
326
+ - **Uniweb hosting** — paid (starts at $14/month per site). Always serves linked sites with JIT prerender, edge SSR, locale-aware routing, foundation/runtime version propagation, and the multi-tenant CMS for non-technical content authors via the visual editor. The right choice when foundation developers or agencies build for clients who manage their own content. The catalog is private and access-segregated — foundations are commercial products licensed by site, not packages on a public registry.
327
+
328
+ → **[Deploying](https://github.com/uniweb/docs/blob/main/development/deploying.md)** — the full menu: picking a deploy path (free vs paid), standalone vs linked, site-bound vs cataloged, the two-verb model, CI-detection, and per-host recipes.
321
329
 
322
330
  ---
323
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,12 +41,12 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/kit": "0.9.10",
45
- "@uniweb/runtime": "0.8.11",
46
- "@uniweb/core": "0.7.10"
44
+ "@uniweb/runtime": "0.8.13",
45
+ "@uniweb/core": "0.7.11",
46
+ "@uniweb/kit": "0.9.11"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.1",
49
+ "@uniweb/build": "0.14.2",
50
50
  "@uniweb/semantic-parser": "1.1.17",
51
51
  "@uniweb/content-reader": "1.1.10"
52
52
  },
@@ -136,12 +136,29 @@ 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)
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)
143
158
  ```
144
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.
161
+
145
162
  ---
146
163
 
147
164
  ## `package.json` `uniweb` configuration
@@ -172,7 +189,7 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
172
189
  **Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
173
190
 
174
191
  - A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb publish @org/name` for these. The CLI requires an explicit name argument so you don't accidentally catalog a foundation that was meant to be site-bound.
175
- - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory — the CLI auto-publishes your local foundation as part of the deploy, under a registry slot scoped to the site itself, with no naming ceremony. The foundation isn't visible in the catalog and isn't owned by the developer who happened to run the deploy.
192
+ - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory — the CLI auto-publishes your local foundation as part of the deploy, **uploaded with the site's other published assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb publish @org/name` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
176
193
 
177
194
  **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means renaming on the registry (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb publish --name marketing-pro` — it persists to `uniweb.id` without touching the workspace.
178
195
 
@@ -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
  /**
@@ -35,8 +35,10 @@
35
35
  * uniweb build --target site # Explicitly build as site
36
36
  * uniweb build --prerender # Force pre-rendering
37
37
  * uniweb build --no-prerender # Skip pre-rendering
38
- * uniweb build --host <name> # Override site.yml deploy.host (e.g.
39
- * netlify, s3-cloudfront, generic-static)
38
+ * uniweb build --host <name> # Pick the host adapter for this build's
39
+ * postBuild step (e.g. cloudflare-pages,
40
+ * s3-cloudfront, github-pages,
41
+ * generic-static). Default: cloudflare-pages.
40
42
  *
41
43
  * Internal flags (called by `uniweb deploy` / `uniweb export`):
42
44
  * --link # Data-only pipeline (Uniweb-edge)
@@ -838,12 +840,25 @@ export async function build(args = []) {
838
840
  foundationDir = resolve(args[foundationDirIndex + 1])
839
841
  }
840
842
 
841
- // --host overrides site.yml's deploy.host for this build's prerender
842
- // step. Validated lazily (inside prerender.js, via the registry).
843
+ // --host names the host adapter for this build's prerender step.
844
+ // Default = 'cloudflare-pages' (resolved inside prerender.js, via the
845
+ // registry). Build does not read deploy.yml; that is the deploy
846
+ // orchestrator's job. See kb/framework/plans/static-host-deploy-adapters.md.
847
+ //
848
+ // `--host` with no value → interactive picker (errors in CI / non-TTY).
849
+ const { readFlagValue } = await import('../utils/args.js')
850
+ const hostFlag = readFlagValue(args, '--host')
843
851
  let host = null
844
- const hostIndex = args.indexOf('--host')
845
- if (hostIndex !== -1 && args[hostIndex + 1]) {
846
- host = args[hostIndex + 1]
852
+ if (hostFlag === null) {
853
+ const { promptForHost } = await import('../utils/host-prompt.js')
854
+ try {
855
+ host = await promptForHost({ args })
856
+ } catch (err) {
857
+ error(err.message)
858
+ process.exit(1)
859
+ }
860
+ } else if (typeof hostFlag === 'string') {
861
+ host = hostFlag
847
862
  }
848
863
 
849
864
  // Auto-detect project type if not specified
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Deploy Command
3
3
  *
4
- * Deploys a site. Host is determined by `deploy.host` in site.yml (or
5
- * `--host <name>` flag). The default is `uniweb`:
4
+ * Deploys a site. Host is determined by the resolved deploy.yml target
5
+ * (or `--target <name>` / `--host <name>` flags). The default is `uniweb`:
6
6
  *
7
7
  * - `uniweb` (default): Uniweb hosting — link-mode + edge JIT prerender.
8
8
  * Foundation loaded by URL from the registry. Requires `uniweb login`
@@ -39,8 +39,10 @@
39
39
  * uniweb deploy Normal deploy (browser may open on first deploy)
40
40
  * uniweb deploy --dry-run Resolve everything but skip the Worker POST
41
41
  * uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
42
- * uniweb deploy --host <name> Static-host flow (e.g., s3-cloudfront,
43
- * generic-static). Overrides site.yml deploy.host.
42
+ * uniweb deploy --target <name> Pick a target from deploy.yml (default: deploy.yml's `default:`)
43
+ * uniweb deploy --host <name> Override the resolved target's host adapter
44
+ * (does not write to deploy.yml on success)
45
+ * uniweb deploy --no-save Skip the auto-save of lastDeploy in deploy.yml
44
46
  *
45
47
  * Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
46
48
  * UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
@@ -60,6 +62,9 @@ import { execSync } from 'node:child_process'
60
62
  import yaml from 'js-yaml'
61
63
 
62
64
  import { detectFoundationType } from '@uniweb/build'
65
+ import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
66
+ import { promptForHost } from '../utils/host-prompt.js'
67
+ import { readFlagValue } from '../utils/args.js'
63
68
 
64
69
  import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
65
70
  import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
@@ -77,6 +82,7 @@ function splitRegistryRef(ref) {
77
82
  const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
78
83
  return m ? { name: m[1], version: m[2] } : null
79
84
  }
85
+
80
86
  import {
81
87
  findWorkspaceRoot,
82
88
  findSites,
@@ -395,6 +401,21 @@ export async function deploy(args = []) {
395
401
  // of the current source's git sha. This flag opts out.
396
402
  const autoPublishFoundation = !args.includes('--no-auto-publish')
397
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
+
398
419
  // Internal escape hatches — see framework/cli/docs/env-vars.md. These
399
420
  // are not user-facing flags; they exist for the platform test team,
400
421
  // CI scripts, and dev-loop unblockers. The bare `deploy` command should
@@ -409,26 +430,70 @@ export async function deploy(args = []) {
409
430
  const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
410
431
 
411
432
  const siteDir = await resolveSiteDir(args)
412
- const backendUrl = getBackendUrl()
413
- 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
+ }
414
438
 
415
439
  // Read site.yml — declares the foundation (required) and optionally the
416
440
  // site.id / site.handle from prior deploys.
417
441
  const siteYmlPath = join(siteDir, 'site.yml')
418
442
  const siteYml = await readSiteYml(siteYmlPath)
419
443
 
420
- // Host dispatch. The default host is `uniweb` — Uniweb hosting
421
- // (link-mode + edge JIT prerender), which is the rest of this
422
- // function. Any other named host is a static-host adapter; hand off
423
- // to it and return. The default flow requires a `foundation:`
424
- // declaration; static-host deploys don't, so this branch comes BEFORE
425
- // the foundation check.
426
- // See kb/framework/plans/static-host-deploy-adapters.md.
427
- const hostFlagIndex = args.indexOf('--host')
428
- const hostFromFlag = hostFlagIndex !== -1 ? args[hostFlagIndex + 1] : null
429
- const host = hostFromFlag || siteYml.deploy?.host || 'uniweb'
444
+ // Host dispatch.
445
+ //
446
+ // Resolution order:
447
+ // 1. --target <name> picks a target from deploy.yml (full config:
448
+ // host + adapter-specific fields)
449
+ // 2. deploy.yml's `default:` target is used when no flag is given
450
+ // 3. With no deploy.yml at all, the implicit default is host: 'uniweb'
451
+ // 4. --host <name> is a one-off override of the resolved target's host
452
+ // and does NOT persist on success (see saveDeployTarget below).
453
+ //
454
+ // The default flow (`uniweb`) requires a `foundation:` declaration;
455
+ // static-host deploys don't, so this branch comes BEFORE the foundation
456
+ // check. See kb/framework/plans/static-host-deploy-adapters.md.
457
+ const targetFromFlag = readFlagValue(args, '--target')
458
+ let hostFromFlag = readFlagValue(args, '--host')
459
+ const noSave = args.includes('--no-save')
460
+
461
+ let deployYml
462
+ try {
463
+ deployYml = await loadDeployYml(siteDir)
464
+ } catch (err) {
465
+ say.err(err.message)
466
+ process.exit(1)
467
+ }
468
+ let resolved
469
+ try {
470
+ resolved = resolveTarget(deployYml, targetFromFlag || null)
471
+ } catch (err) {
472
+ say.err(err.message)
473
+ process.exit(1)
474
+ }
475
+ // --host with no value → interactive picker. Pre-selects the resolved
476
+ // target's host so Enter does the obvious thing.
477
+ if (hostFromFlag === null) {
478
+ try {
479
+ hostFromFlag = await promptForHost({ args, preselect: resolved.host })
480
+ } catch (err) {
481
+ say.err(err.message)
482
+ process.exit(1)
483
+ }
484
+ }
485
+ const host = hostFromFlag || resolved.host
486
+ const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
487
+ // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override
488
+ // (we don't want a one-off experiment to rewrite the file).
489
+ const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
490
+
430
491
  if (host !== 'uniweb') {
431
- await deployStaticHost(siteDir, siteYml, host, { dryRun })
492
+ await deployStaticHost(siteDir, host, resolved, {
493
+ dryRun,
494
+ autoSave,
495
+ hostOverridden,
496
+ })
432
497
  return
433
498
  }
434
499
 
@@ -552,7 +617,7 @@ export async function deploy(args = []) {
552
617
  // doesn't fail the whole deploy.
553
618
  const desiredFeatures = readFeaturesFromYaml(siteYml)
554
619
 
555
- const cliToken = await ensureAuth({ command: 'Deploying' })
620
+ const cliToken = await ensureAuth({ command: 'Deploying', args })
556
621
 
557
622
  // Always rebuild unless the user explicitly opts out with --skip-build.
558
623
  // A stale dist/ from a previous build + edited content on disk would
@@ -944,19 +1009,40 @@ export async function deploy(args = []) {
944
1009
  if (handleResolved) {
945
1010
  console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
946
1011
  }
1012
+
1013
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save (and
1014
+ // on --host overrides, but uniweb-host can't be reached via override
1015
+ // since the override branches into deployStaticHost above).
1016
+ await persistLastDeploy(siteDir, {
1017
+ targetName: resolved.targetName,
1018
+ targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
1019
+ autoSave,
1020
+ lastDeploy: {
1021
+ at: deployReceipt.deployedAt,
1022
+ host: 'uniweb',
1023
+ url: deployReceipt.url,
1024
+ siteId: siteIdResolved,
1025
+ handle: handleResolved,
1026
+ foundation: {
1027
+ shape: 'linked',
1028
+ ref: foundationRef,
1029
+ },
1030
+ runtime: runtimeVersion,
1031
+ },
1032
+ })
947
1033
  }
948
1034
 
949
1035
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
950
1036
  //
951
- // Distinct from the uniweb-edge flow above. Picked when site.yml's
952
- // `deploy.host` (or --host flag) names a static-host adapter
953
- // registered in @uniweb/build/hosts. Always runs `uniweb build`
954
- // (bundle mode + prerender) first, then hands dist/ to the adapter's
955
- // deploy hook for upload + invalidation.
1037
+ // Distinct from the uniweb-edge flow above. Picked when the resolved
1038
+ // deploy.yml target (or --host override) names a static-host adapter
1039
+ // registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
1040
+ // mode + prerender) first, then hands dist/ to the adapter's deploy hook
1041
+ // for upload + invalidation.
956
1042
  //
957
1043
  // See kb/framework/plans/static-host-deploy-adapters.md.
958
1044
 
959
- async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
1045
+ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
960
1046
  let getAdapter
961
1047
  try {
962
1048
  ({ getAdapter } = await import('@uniweb/build/hosts'))
@@ -971,7 +1057,7 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
971
1057
  adapter = getAdapter(hostName)
972
1058
  } catch (err) {
973
1059
  say.err(err.message)
974
- say.dim(`Set deploy.host in site.yml or pass --host=<name>. See \`uniweb deploy --help\`.`)
1060
+ say.dim('Set the host in deploy.yml or pass --host=<name>. See `uniweb deploy --help`.')
975
1061
  process.exit(1)
976
1062
  }
977
1063
 
@@ -982,17 +1068,18 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
982
1068
  process.exit(1)
983
1069
  }
984
1070
 
985
- const deployConfig = siteYml.deploy || {}
1071
+ const deployConfig = resolved.config || {}
986
1072
  const distDir = join(siteDir, 'dist')
987
1073
 
988
1074
  if (dryRun) {
989
1075
  say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
990
1076
  say.dim(`Site dir : ${siteDir}`)
991
1077
  say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
992
- say.dim(`deploy.bucket : ${deployConfig.bucket || '(unset)'}`)
993
- say.dim(`deploy.distId : ${deployConfig.distributionId || '(unset)'}`)
994
- say.dim(`deploy.region : ${deployConfig.region || '(unset)'}`)
995
- say.dim(`deploy.profile : ${deployConfig.profile || '(default AWS chain)'}`)
1078
+ say.dim(`Target : ${resolved.targetName}`)
1079
+ say.dim(`bucket : ${deployConfig.bucket || '(unset)'}`)
1080
+ say.dim(`distributionId : ${deployConfig.distributionId || '(unset)'}`)
1081
+ say.dim(`region : ${deployConfig.region || '(unset)'}`)
1082
+ say.dim(`profile : ${deployConfig.profile || '(default AWS chain)'}`)
996
1083
  return
997
1084
  }
998
1085
 
@@ -1045,6 +1132,39 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
1045
1132
  }
1046
1133
  throw err
1047
1134
  }
1135
+
1136
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
1137
+ // on ad-hoc --host overrides — see autoSave gating in deploy().
1138
+ await persistLastDeploy(siteDir, {
1139
+ targetName: resolved.targetName,
1140
+ targetConfig: resolved.fromFile ? null : { host: hostName, ...deployConfig },
1141
+ autoSave,
1142
+ lastDeploy: {
1143
+ at: new Date().toISOString(),
1144
+ host: hostName,
1145
+ // Static hosts know their public URL only via the user's CDN config;
1146
+ // we don't have it on hand. Future: pull from a known field.
1147
+ },
1148
+ })
1149
+ if (hostOverridden && !dryRun) {
1150
+ say.dim('--host override active — did not write to deploy.yml. Edit deploy.yml to make this permanent.')
1151
+ }
1152
+ }
1153
+
1154
+ // ─── deploy.yml lastDeploy persistence ──────────────────────────
1155
+
1156
+ async function persistLastDeploy(siteDir, opts) {
1157
+ if (opts.autoSave === 'off') return
1158
+ try {
1159
+ const result = await recordLastDeploy(siteDir, opts)
1160
+ if (result?.created) {
1161
+ say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
1162
+ }
1163
+ } catch (err) {
1164
+ // The deploy itself succeeded — never fail the whole command on a
1165
+ // memo-write error. Surface it so the user can fix the file.
1166
+ say.dim(`Could not update deploy.yml: ${err.message}`)
1167
+ }
1048
1168
  }
1049
1169
 
1050
1170
  // ─── site.yml ──────────────────────────────────────────────
@@ -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
+ }
@@ -17,9 +17,10 @@
17
17
  * Usage:
18
18
  * uniweb export Produce dist/ for static hosting
19
19
  * uniweb export --no-prerender Skip per-page prerendered HTML
20
- * uniweb export --host <name> Select a host adapter (e.g.
21
- * netlify, s3-cloudfront, generic-static).
22
- * Overrides site.yml's deploy.host.
20
+ * uniweb export --host <name> Pick a host adapter for postBuild
21
+ * (e.g. cloudflare-pages, s3-cloudfront,
22
+ * github-pages, generic-static).
23
+ * Default: cloudflare-pages.
23
24
  */
24
25
 
25
26
  import { execSync } from 'node:child_process'
@@ -53,9 +54,21 @@ export async function exportSite(args = []) {
53
54
  const buildArgs = ['build', '--bundle']
54
55
  if (noPrerender) buildArgs.push('--no-prerender')
55
56
 
56
- const hostIndex = args.indexOf('--host')
57
- if (hostIndex !== -1 && args[hostIndex + 1]) {
58
- buildArgs.push('--host', args[hostIndex + 1])
57
+ const { readFlagValue } = await import('../utils/args.js')
58
+ const hostFlag = readFlagValue(args, '--host')
59
+ if (hostFlag === null) {
60
+ // --host with no value → prompt here so the build subprocess gets
61
+ // a concrete value (and doesn't re-prompt against its own argv).
62
+ const { promptForHost } = await import('../utils/host-prompt.js')
63
+ try {
64
+ const chosen = await promptForHost({ args })
65
+ buildArgs.push('--host', chosen)
66
+ } catch (err) {
67
+ say.err(err.message)
68
+ process.exit(1)
69
+ }
70
+ } else if (typeof hostFlag === 'string') {
71
+ buildArgs.push('--host', hostFlag)
59
72
  }
60
73
 
61
74
  say.info('Exporting site (vite build → dist/)…')
@@ -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'