uniweb 0.12.9 → 0.12.10

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.10",
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/kit": "0.9.11",
46
+ "@uniweb/core": "0.7.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
  },
@@ -172,7 +172,7 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
172
172
  **Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
173
173
 
174
174
  - 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.
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, **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
176
 
177
177
  **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
178
 
@@ -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,
@@ -417,18 +423,59 @@ export async function deploy(args = []) {
417
423
  const siteYmlPath = join(siteDir, 'site.yml')
418
424
  const siteYml = await readSiteYml(siteYmlPath)
419
425
 
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'
426
+ // Host dispatch.
427
+ //
428
+ // Resolution order:
429
+ // 1. --target <name> picks a target from deploy.yml (full config:
430
+ // host + adapter-specific fields)
431
+ // 2. deploy.yml's `default:` target is used when no flag is given
432
+ // 3. With no deploy.yml at all, the implicit default is host: 'uniweb'
433
+ // 4. --host <name> is a one-off override of the resolved target's host
434
+ // and does NOT persist on success (see saveDeployTarget below).
435
+ //
436
+ // The default flow (`uniweb`) requires a `foundation:` declaration;
437
+ // static-host deploys don't, so this branch comes BEFORE the foundation
438
+ // check. See kb/framework/plans/static-host-deploy-adapters.md.
439
+ const targetFromFlag = readFlagValue(args, '--target')
440
+ let hostFromFlag = readFlagValue(args, '--host')
441
+ const noSave = args.includes('--no-save')
442
+
443
+ let deployYml
444
+ try {
445
+ deployYml = await loadDeployYml(siteDir)
446
+ } catch (err) {
447
+ say.err(err.message)
448
+ process.exit(1)
449
+ }
450
+ let resolved
451
+ try {
452
+ resolved = resolveTarget(deployYml, targetFromFlag || null)
453
+ } catch (err) {
454
+ say.err(err.message)
455
+ process.exit(1)
456
+ }
457
+ // --host with no value → interactive picker. Pre-selects the resolved
458
+ // target's host so Enter does the obvious thing.
459
+ if (hostFromFlag === null) {
460
+ try {
461
+ hostFromFlag = await promptForHost({ args, preselect: resolved.host })
462
+ } catch (err) {
463
+ say.err(err.message)
464
+ process.exit(1)
465
+ }
466
+ }
467
+ const host = hostFromFlag || resolved.host
468
+ const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
469
+ // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override
470
+ // (we don't want a one-off experiment to rewrite the file).
471
+ const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
472
+
430
473
  if (host !== 'uniweb') {
431
- await deployStaticHost(siteDir, siteYml, host, { dryRun })
474
+ await deployStaticHost(siteDir, host, resolved, {
475
+ dryRun,
476
+ autoSave,
477
+ hostOverridden,
478
+ })
432
479
  return
433
480
  }
434
481
 
@@ -944,19 +991,40 @@ export async function deploy(args = []) {
944
991
  if (handleResolved) {
945
992
  console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
946
993
  }
994
+
995
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save (and
996
+ // on --host overrides, but uniweb-host can't be reached via override
997
+ // since the override branches into deployStaticHost above).
998
+ await persistLastDeploy(siteDir, {
999
+ targetName: resolved.targetName,
1000
+ targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
1001
+ autoSave,
1002
+ lastDeploy: {
1003
+ at: deployReceipt.deployedAt,
1004
+ host: 'uniweb',
1005
+ url: deployReceipt.url,
1006
+ siteId: siteIdResolved,
1007
+ handle: handleResolved,
1008
+ foundation: {
1009
+ shape: 'linked',
1010
+ ref: foundationRef,
1011
+ },
1012
+ runtime: runtimeVersion,
1013
+ },
1014
+ })
947
1015
  }
948
1016
 
949
1017
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
950
1018
  //
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.
1019
+ // Distinct from the uniweb-edge flow above. Picked when the resolved
1020
+ // deploy.yml target (or --host override) names a static-host adapter
1021
+ // registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
1022
+ // mode + prerender) first, then hands dist/ to the adapter's deploy hook
1023
+ // for upload + invalidation.
956
1024
  //
957
1025
  // See kb/framework/plans/static-host-deploy-adapters.md.
958
1026
 
959
- async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
1027
+ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
960
1028
  let getAdapter
961
1029
  try {
962
1030
  ({ getAdapter } = await import('@uniweb/build/hosts'))
@@ -971,7 +1039,7 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
971
1039
  adapter = getAdapter(hostName)
972
1040
  } catch (err) {
973
1041
  say.err(err.message)
974
- say.dim(`Set deploy.host in site.yml or pass --host=<name>. See \`uniweb deploy --help\`.`)
1042
+ say.dim('Set the host in deploy.yml or pass --host=<name>. See `uniweb deploy --help`.')
975
1043
  process.exit(1)
976
1044
  }
977
1045
 
@@ -982,17 +1050,18 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
982
1050
  process.exit(1)
983
1051
  }
984
1052
 
985
- const deployConfig = siteYml.deploy || {}
1053
+ const deployConfig = resolved.config || {}
986
1054
  const distDir = join(siteDir, 'dist')
987
1055
 
988
1056
  if (dryRun) {
989
1057
  say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
990
1058
  say.dim(`Site dir : ${siteDir}`)
991
1059
  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)'}`)
1060
+ say.dim(`Target : ${resolved.targetName}`)
1061
+ say.dim(`bucket : ${deployConfig.bucket || '(unset)'}`)
1062
+ say.dim(`distributionId : ${deployConfig.distributionId || '(unset)'}`)
1063
+ say.dim(`region : ${deployConfig.region || '(unset)'}`)
1064
+ say.dim(`profile : ${deployConfig.profile || '(default AWS chain)'}`)
996
1065
  return
997
1066
  }
998
1067
 
@@ -1045,6 +1114,39 @@ async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
1045
1114
  }
1046
1115
  throw err
1047
1116
  }
1117
+
1118
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
1119
+ // on ad-hoc --host overrides — see autoSave gating in deploy().
1120
+ await persistLastDeploy(siteDir, {
1121
+ targetName: resolved.targetName,
1122
+ targetConfig: resolved.fromFile ? null : { host: hostName, ...deployConfig },
1123
+ autoSave,
1124
+ lastDeploy: {
1125
+ at: new Date().toISOString(),
1126
+ host: hostName,
1127
+ // Static hosts know their public URL only via the user's CDN config;
1128
+ // we don't have it on hand. Future: pull from a known field.
1129
+ },
1130
+ })
1131
+ if (hostOverridden && !dryRun) {
1132
+ say.dim('--host override active — did not write to deploy.yml. Edit deploy.yml to make this permanent.')
1133
+ }
1134
+ }
1135
+
1136
+ // ─── deploy.yml lastDeploy persistence ──────────────────────────
1137
+
1138
+ async function persistLastDeploy(siteDir, opts) {
1139
+ if (opts.autoSave === 'off') return
1140
+ try {
1141
+ const result = await recordLastDeploy(siteDir, opts)
1142
+ if (result?.created) {
1143
+ say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
1144
+ }
1145
+ } catch (err) {
1146
+ // The deploy itself succeeded — never fail the whole command on a
1147
+ // memo-write error. Surface it so the user can fix the file.
1148
+ say.dim(`Could not update deploy.yml: ${err.message}`)
1149
+ }
1048
1150
  }
1049
1151
 
1050
1152
  // ─── site.yml ──────────────────────────────────────────────
@@ -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/)…')
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-04T22:31:28.093Z",
3
+ "generatedAt": "2026-05-05T18:36:54.411Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.1",
6
+ "version": "0.14.2",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -24,7 +24,7 @@
24
24
  "deps": []
25
25
  },
26
26
  "@uniweb/core": {
27
- "version": "0.7.10",
27
+ "version": "0.7.11",
28
28
  "path": "framework/core",
29
29
  "deps": [
30
30
  "@uniweb/semantic-parser",
@@ -42,7 +42,7 @@
42
42
  "deps": []
43
43
  },
44
44
  "@uniweb/kit": {
45
- "version": "0.9.10",
45
+ "version": "0.9.11",
46
46
  "path": "framework/kit",
47
47
  "deps": [
48
48
  "@uniweb/core"
@@ -59,7 +59,7 @@
59
59
  "deps": []
60
60
  },
61
61
  "@uniweb/runtime": {
62
- "version": "0.8.11",
62
+ "version": "0.8.13",
63
63
  "path": "framework/runtime",
64
64
  "deps": [
65
65
  "@uniweb/core",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.5",
95
+ "version": "0.4.6",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
@@ -0,0 +1,37 @@
1
+ /**
2
+ * argv parsing helpers shared across CLI commands.
3
+ */
4
+
5
+ /**
6
+ * Read `--flag value` from argv. Accepts both `--flag value` and
7
+ * `--flag=value`.
8
+ *
9
+ * Returns:
10
+ * - undefined when the flag is absent
11
+ * - null when the flag is present without a value (last arg, next is
12
+ * another flag, or `--flag=` empty form)
13
+ * - string when the flag carries a value
14
+ *
15
+ * The three-state return lets callers distinguish "not given" (e.g.,
16
+ * fall back to a default) from "given but empty" (e.g., trigger an
17
+ * interactive prompt).
18
+ *
19
+ * @param {string[]} args
20
+ * @param {string} name — Including the leading dashes, e.g. '--host'.
21
+ * @returns {string | null | undefined}
22
+ */
23
+ export function readFlagValue(args, name) {
24
+ const eqPrefix = name + '='
25
+ for (let i = 0; i < args.length; i++) {
26
+ if (args[i] === name) {
27
+ const next = args[i + 1]
28
+ if (next === undefined || next.startsWith('--')) return null
29
+ return next
30
+ }
31
+ if (args[i].startsWith(eqPrefix)) {
32
+ const v = args[i].slice(eqPrefix.length)
33
+ return v === '' ? null : v
34
+ }
35
+ }
36
+ return undefined
37
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Interactive host adapter selection
3
+ *
4
+ * Prompts the user to pick a host adapter from the registry. Used when
5
+ * `--host` is passed without a value to `uniweb deploy / build / export`.
6
+ *
7
+ * Non-interactive contexts (CI, piped input, --non-interactive) get a
8
+ * structured error instead of a prompt — never silently default to
9
+ * something the user didn't pick.
10
+ */
11
+
12
+ import { promptSelect } from './workspace.js'
13
+ import { isNonInteractive } from './interactive.js'
14
+
15
+ /**
16
+ * Pick a host adapter, optionally with a pre-selection.
17
+ *
18
+ * @param {object} opts
19
+ * @param {string[]} opts.args — Argv, used only to gate non-interactive mode.
20
+ * @param {string|null} [opts.preselect] — Suggested adapter name; the prompt
21
+ * highlights this so Enter accepts it without arrow-key navigation.
22
+ * @returns {Promise<string>} The chosen adapter name.
23
+ * @throws {Error} When non-interactive (with the registry list in the message),
24
+ * or when the user aborts the prompt.
25
+ */
26
+ export async function promptForHost({ args, preselect = null } = {}) {
27
+ // Lazy-load so this module doesn't pull @uniweb/build at import time
28
+ // for callers that never reach the prompt path.
29
+ const { listAdapters } = await import('@uniweb/build/hosts')
30
+ const adapters = listAdapters()
31
+
32
+ if (isNonInteractive(args || [])) {
33
+ const list = adapters.join(', ')
34
+ throw new Error(
35
+ `--host requires a value when running non-interactively. Known adapters: ${list}.`
36
+ )
37
+ }
38
+
39
+ // promptSelect doesn't expose initial-index, so move the preselect to
40
+ // the top of the list — the menu still highlights index 0 by default.
41
+ const ordered = preselect && adapters.includes(preselect)
42
+ ? [preselect, ...adapters.filter(a => a !== preselect)]
43
+ : adapters
44
+
45
+ const choice = await promptSelect('Pick a host adapter:', ordered)
46
+ if (!choice) {
47
+ throw new Error('Host selection cancelled.')
48
+ }
49
+ return choice
50
+ }