uniweb 0.12.8 → 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 +13 -5
- package/package.json +7 -7
- package/partials/agents.md +1 -1
- package/src/commands/build.js +31 -5
- package/src/commands/deploy.js +552 -71
- package/src/commands/export.js +25 -4
- package/src/framework-index.json +6 -6
- package/src/utils/args.js +37 -0
- package/src/utils/config.js +8 -2
- package/src/utils/host-prompt.js +50 -0
- package/templates/foundation/package.json.hbs +1 -1
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.
|
|
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
|
-
- **
|
|
316
|
-
- **Linked mode** — the foundation
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/kit": "0.9.
|
|
46
|
-
"@uniweb/
|
|
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.
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/build": "0.14.2",
|
|
50
|
+
"@uniweb/semantic-parser": "1.1.17",
|
|
51
|
+
"@uniweb/content-reader": "1.1.10"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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,
|
|
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
|
|
package/src/commands/build.js
CHANGED
|
@@ -35,6 +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> # 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.
|
|
38
42
|
*
|
|
39
43
|
* Internal flags (called by `uniweb deploy` / `uniweb export`):
|
|
40
44
|
* --link # Data-only pipeline (Uniweb-edge)
|
|
@@ -540,7 +544,7 @@ async function resolveFoundationDirForSite(siteDir, siteConfig) {
|
|
|
540
544
|
* Build a site
|
|
541
545
|
*/
|
|
542
546
|
async function buildSite(projectDir, options = {}) {
|
|
543
|
-
const { prerender = false, foundationDir, siteConfig = null } = options
|
|
547
|
+
const { prerender = false, foundationDir, siteConfig = null, host = null } = options
|
|
544
548
|
|
|
545
549
|
info('Building site...')
|
|
546
550
|
|
|
@@ -613,6 +617,7 @@ async function buildSite(projectDir, options = {}) {
|
|
|
613
617
|
|
|
614
618
|
const result = await prerenderSite(projectDir, {
|
|
615
619
|
foundationDir: foundationDir || resolveFoundationDir(projectDir, siteConfig),
|
|
620
|
+
host,
|
|
616
621
|
onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
|
|
617
622
|
})
|
|
618
623
|
|
|
@@ -710,7 +715,7 @@ async function discoverWorkspacePackages(workspaceDir) {
|
|
|
710
715
|
* Build all packages in a workspace
|
|
711
716
|
*/
|
|
712
717
|
async function buildWorkspace(workspaceDir, options = {}) {
|
|
713
|
-
const { prerenderFlag, noPrerenderFlag } = options
|
|
718
|
+
const { prerenderFlag, noPrerenderFlag, host = null } = options
|
|
714
719
|
|
|
715
720
|
log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
|
|
716
721
|
log('')
|
|
@@ -757,7 +762,7 @@ async function buildWorkspace(workspaceDir, options = {}) {
|
|
|
757
762
|
// Resolve foundation directory for this site
|
|
758
763
|
const foundationDir = resolveFoundationDir(site.path, siteConfig)
|
|
759
764
|
|
|
760
|
-
await buildSite(site.path, { prerender, foundationDir, siteConfig })
|
|
765
|
+
await buildSite(site.path, { prerender, foundationDir, siteConfig, host })
|
|
761
766
|
log('')
|
|
762
767
|
}
|
|
763
768
|
|
|
@@ -835,6 +840,27 @@ export async function build(args = []) {
|
|
|
835
840
|
foundationDir = resolve(args[foundationDirIndex + 1])
|
|
836
841
|
}
|
|
837
842
|
|
|
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')
|
|
851
|
+
let host = null
|
|
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
|
|
862
|
+
}
|
|
863
|
+
|
|
838
864
|
// Auto-detect project type if not specified
|
|
839
865
|
if (!targetType) {
|
|
840
866
|
targetType = detectProjectType(projectDir)
|
|
@@ -868,7 +894,7 @@ export async function build(args = []) {
|
|
|
868
894
|
// Run appropriate build
|
|
869
895
|
try {
|
|
870
896
|
if (targetType === 'workspace') {
|
|
871
|
-
await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag })
|
|
897
|
+
await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag, host })
|
|
872
898
|
} else if (targetType === 'foundation') {
|
|
873
899
|
await buildFoundation(projectDir)
|
|
874
900
|
} else {
|
|
@@ -898,7 +924,7 @@ export async function build(args = []) {
|
|
|
898
924
|
if (prerenderFlag) prerender = true
|
|
899
925
|
if (noPrerenderFlag) prerender = false
|
|
900
926
|
|
|
901
|
-
await buildSite(projectDir, { prerender, foundationDir, siteConfig })
|
|
927
|
+
await buildSite(projectDir, { prerender, foundationDir, siteConfig, host })
|
|
902
928
|
}
|
|
903
929
|
} catch (err) {
|
|
904
930
|
error(err.message)
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deploy Command
|
|
3
3
|
*
|
|
4
|
-
* Deploys a site
|
|
5
|
-
*
|
|
6
|
-
* For static-host artifacts (no upload), see `uniweb export`.
|
|
4
|
+
* Deploys a site. Host is determined by the resolved deploy.yml target
|
|
5
|
+
* (or `--target <name>` / `--host <name>` flags). The default is `uniweb`:
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* - `uniweb` (default): Uniweb hosting — link-mode + edge JIT prerender.
|
|
8
|
+
* Foundation loaded by URL from the registry. Requires `uniweb login`
|
|
9
|
+
* and a `foundation:` declaration in site.yml.
|
|
10
|
+
*
|
|
11
|
+
* - Static-host adapters (`s3-cloudfront`, `cloudflare-pages`,
|
|
12
|
+
* `github-pages`, `generic-static`, …): build dist/ in bundle-mode
|
|
13
|
+
* and hand it to a host adapter for upload + invalidation. No login,
|
|
14
|
+
* no edge. See kb/framework/plans/static-host-deploy-adapters.md.
|
|
15
|
+
*
|
|
16
|
+
* For static-host artifacts WITHOUT upload, see `uniweb export`.
|
|
17
|
+
*
|
|
18
|
+
* Default-flow steps:
|
|
9
19
|
* 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
|
|
10
|
-
* 2. Resolve runtime (default: GET /
|
|
20
|
+
* 2. Resolve runtime (default: GET /runtime/latest from the Worker).
|
|
11
21
|
* 3. ensureAuth() → bearer CLI JWT from ~/.uniweb/auth.json.
|
|
12
22
|
* 4. Build `dist/` if missing.
|
|
13
23
|
* 5. Load dist/site-content.json → extract `languages` for the capability
|
|
@@ -19,9 +29,9 @@
|
|
|
19
29
|
* - publishToken returned → fast path.
|
|
20
30
|
* - needsReview:true + reviewUrl → open browser, wait for callback,
|
|
21
31
|
* consume { publishToken, siteId, handle }.
|
|
22
|
-
* 9. POST Worker /
|
|
32
|
+
* 9. POST Worker /publish/check to confirm foundation + runtime
|
|
23
33
|
* exist and the token's namespace claim matches.
|
|
24
|
-
* 10. POST Worker /
|
|
34
|
+
* 10. POST Worker /publish with the full payload.
|
|
25
35
|
* 11. On first-deploy create flow: write site.id + site.handle back into
|
|
26
36
|
* site.yml so subsequent deploys fast-path.
|
|
27
37
|
*
|
|
@@ -29,6 +39,10 @@
|
|
|
29
39
|
* uniweb deploy Normal deploy (browser may open on first deploy)
|
|
30
40
|
* uniweb deploy --dry-run Resolve everything but skip the Worker POST
|
|
31
41
|
* uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
|
|
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
|
|
32
46
|
*
|
|
33
47
|
* Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
|
|
34
48
|
* UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
|
|
@@ -48,6 +62,9 @@ import { execSync } from 'node:child_process'
|
|
|
48
62
|
import yaml from 'js-yaml'
|
|
49
63
|
|
|
50
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'
|
|
51
68
|
|
|
52
69
|
import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
|
|
53
70
|
import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
|
|
@@ -65,6 +82,7 @@ function splitRegistryRef(ref) {
|
|
|
65
82
|
const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
|
|
66
83
|
return m ? { name: m[1], version: m[2] } : null
|
|
67
84
|
}
|
|
85
|
+
|
|
68
86
|
import {
|
|
69
87
|
findWorkspaceRoot,
|
|
70
88
|
findSites,
|
|
@@ -404,6 +422,63 @@ export async function deploy(args = []) {
|
|
|
404
422
|
// site.id / site.handle from prior deploys.
|
|
405
423
|
const siteYmlPath = join(siteDir, 'site.yml')
|
|
406
424
|
const siteYml = await readSiteYml(siteYmlPath)
|
|
425
|
+
|
|
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
|
+
|
|
473
|
+
if (host !== 'uniweb') {
|
|
474
|
+
await deployStaticHost(siteDir, host, resolved, {
|
|
475
|
+
dryRun,
|
|
476
|
+
autoSave,
|
|
477
|
+
hostOverridden,
|
|
478
|
+
})
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
407
482
|
if (!siteYml.foundation) {
|
|
408
483
|
say.err('site.yml is missing `foundation`.')
|
|
409
484
|
say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
|
|
@@ -512,7 +587,7 @@ export async function deploy(args = []) {
|
|
|
512
587
|
if (!runtimeVersion) {
|
|
513
588
|
runtimeVersion = await fetchLatestRuntime(workerUrl)
|
|
514
589
|
if (!runtimeVersion) {
|
|
515
|
-
say.err('Could not resolve a runtime version (no runtime: in site.yml, /
|
|
590
|
+
say.err('Could not resolve a runtime version (no runtime: in site.yml, /runtime/latest failed).')
|
|
516
591
|
process.exit(1)
|
|
517
592
|
}
|
|
518
593
|
say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
|
|
@@ -542,11 +617,12 @@ export async function deploy(args = []) {
|
|
|
542
617
|
// serving edge, not here). Always --link: the edge serves a runtime
|
|
543
618
|
// template + per-site base.html, never a self-contained vite bundle.
|
|
544
619
|
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
620
|
+
// Phase 4d: workspace-local foundations carry the `~self/{name}@{ver}`
|
|
621
|
+
// placeholder at this point; the canonical `~{siteId}/...` ref isn't
|
|
622
|
+
// known until authorize returns. Link mode doesn't run vite or fetch
|
|
623
|
+
// the foundation, so site-content.json's foundation field reflects
|
|
624
|
+
// whatever's in site.yml — that's fine because the publish payload
|
|
625
|
+
// overrides it with the canonical form post-authorize.
|
|
550
626
|
//
|
|
551
627
|
// Spawn the SAME CLI binary that's currently running rather than
|
|
552
628
|
// `npx uniweb build` — npx walks node_modules and would resolve to
|
|
@@ -556,9 +632,7 @@ export async function deploy(args = []) {
|
|
|
556
632
|
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
|
|
557
633
|
cwd: siteDir,
|
|
558
634
|
stdio: 'inherit',
|
|
559
|
-
env:
|
|
560
|
-
? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
|
|
561
|
-
: process.env,
|
|
635
|
+
env: process.env,
|
|
562
636
|
})
|
|
563
637
|
console.log('')
|
|
564
638
|
} else if (!existsSync(contentPath)) {
|
|
@@ -693,9 +767,16 @@ export async function deploy(args = []) {
|
|
|
693
767
|
// CLI can write `features:` back into site.yml accurately. Older
|
|
694
768
|
// PHP that doesn't include this field is a no-op.
|
|
695
769
|
mintedFeatures = Array.isArray(cb.features) ? cb.features : null
|
|
770
|
+
// Phase 4d: workspace-local foundation deploys on the create flow
|
|
771
|
+
// need the rewritten `~{siteId}/{name}@{ver}` ref + upload endpoint.
|
|
772
|
+
// PHP/unicloud put them in the finalize response; the web app
|
|
773
|
+
// forwards them to the loopback. Catalog-ref deploys leave them
|
|
774
|
+
// undefined and we fall back to the placeholder/derived URL below.
|
|
775
|
+
if (cb.foundationRef) foundation = cb.foundationRef
|
|
776
|
+
if (cb.foundationUploadUrl) foundationUploadUrl = cb.foundationUploadUrl
|
|
696
777
|
// Review path: Worker URLs are implicit (we derive them from config).
|
|
697
|
-
publishUrl = `${workerUrl}/
|
|
698
|
-
validateUrl = `${workerUrl}/
|
|
778
|
+
publishUrl = `${workerUrl}/publish`
|
|
779
|
+
validateUrl = `${workerUrl}/publish/check`
|
|
699
780
|
} else {
|
|
700
781
|
publishToken = authRes.publishToken
|
|
701
782
|
siteIdResolved = authRes.siteId
|
|
@@ -731,7 +812,7 @@ export async function deploy(args = []) {
|
|
|
731
812
|
// Phase 4d: upload site-bound foundation files directly. Replaces the
|
|
732
813
|
// pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
|
|
733
814
|
// canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
|
|
734
|
-
// /
|
|
815
|
+
// /foundations endpoint accepts the publish token's siteId claim
|
|
735
816
|
// for this scope.
|
|
736
817
|
if (localFoundation) {
|
|
737
818
|
say.info(`Building foundation at ${localFoundation.relPath}…`)
|
|
@@ -749,7 +830,7 @@ export async function deploy(args = []) {
|
|
|
749
830
|
|
|
750
831
|
say.info(`Uploading foundation as ${foundation}…`)
|
|
751
832
|
const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
|
|
752
|
-
const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/
|
|
833
|
+
const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
|
|
753
834
|
const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
|
|
754
835
|
await callFoundationUpload({
|
|
755
836
|
url: foundationPublishUrl,
|
|
@@ -784,36 +865,60 @@ export async function deploy(args = []) {
|
|
|
784
865
|
process.exit(1)
|
|
785
866
|
}
|
|
786
867
|
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
868
|
+
// Collect compiled collection JSON files from dist/data/. The framework
|
|
869
|
+
// emits these for `collection:` data sources — `<name>.json` cascade
|
|
870
|
+
// payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
|
|
871
|
+
// declared. Editor publish has no equivalent (collections live in the DB);
|
|
872
|
+
// CLI sites need them shipped as static R2 objects.
|
|
873
|
+
//
|
|
874
|
+
// Read BEFORE the asset pipeline so the asset scan can pick up image
|
|
875
|
+
// refs in collection JSON (e.g. `article.image: "/covers/foo.svg"`)
|
|
876
|
+
// and the rewrite can swap them for CDN URLs alongside locale content.
|
|
877
|
+
const dataFiles = await collectDataFiles(distDir)
|
|
878
|
+
// Decode each data file as JSON so the asset scan can walk the tree;
|
|
879
|
+
// mutated in place by the rewrite step. Re-stringified before publish.
|
|
880
|
+
const dataFileObjects = {}
|
|
881
|
+
for (const [k, raw] of Object.entries(dataFiles)) {
|
|
882
|
+
try {
|
|
883
|
+
dataFileObjects[k] = JSON.parse(raw)
|
|
884
|
+
} catch {
|
|
885
|
+
dataFileObjects[k] = null // unparseable — skip rewrite, ship as-is
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (Object.keys(dataFiles).length > 0) {
|
|
889
|
+
say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
|
|
893
|
+
// hits (public/, data file refs) to S3, then rewrite each locale's
|
|
894
|
+
// siteContent + each parsed data file so the runtime resolves CDN URLs at
|
|
895
|
+
// render time. Assets are locale-shared (they live in dist/assets/ +
|
|
896
|
+
// public/ regardless of language); diff/upload runs once and the rewrite
|
|
897
|
+
// walks every locale's content tree + every data-file JSON tree.
|
|
792
898
|
// Skipped with --skip-assets.
|
|
793
899
|
if (!skipAssets) {
|
|
794
900
|
await uploadAssetsAndRewriteContent({
|
|
795
901
|
siteDir,
|
|
796
902
|
localeContents,
|
|
903
|
+
dataFileObjects,
|
|
797
904
|
siteYml,
|
|
798
905
|
theme,
|
|
799
906
|
backendUrl,
|
|
800
907
|
cliToken,
|
|
801
908
|
siteId: siteIdResolved,
|
|
802
909
|
})
|
|
910
|
+
// Re-stringify any data-file JSON that the rewrite step mutated, so the
|
|
911
|
+
// publish payload below sees the rewritten URLs. Untouched files round-
|
|
912
|
+
// trip identically.
|
|
913
|
+
for (const k of Object.keys(dataFiles)) {
|
|
914
|
+
if (dataFileObjects[k] !== null) {
|
|
915
|
+
dataFiles[k] = JSON.stringify(dataFileObjects[k])
|
|
916
|
+
}
|
|
917
|
+
}
|
|
803
918
|
} else {
|
|
804
919
|
say.dim('Skipping asset upload (--skip-assets).')
|
|
805
920
|
}
|
|
806
921
|
|
|
807
|
-
// Collect compiled collection JSON files from dist/data/. The framework
|
|
808
|
-
// emits these for `collection:` data sources — `<name>.json` cascade
|
|
809
|
-
// payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
|
|
810
|
-
// declared. Editor publish has no equivalent (collections live in the DB);
|
|
811
|
-
// CLI sites need them shipped as static R2 objects.
|
|
812
|
-
const dataFiles = await collectDataFiles(distDir)
|
|
813
|
-
if (Object.keys(dataFiles).length > 0) {
|
|
814
|
-
say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
|
|
815
|
-
}
|
|
816
|
-
|
|
817
922
|
say.info('Publishing…')
|
|
818
923
|
const publishPayload = {
|
|
819
924
|
foundation,
|
|
@@ -886,6 +991,162 @@ export async function deploy(args = []) {
|
|
|
886
991
|
if (handleResolved) {
|
|
887
992
|
console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
|
|
888
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
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
|
|
1018
|
+
//
|
|
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.
|
|
1024
|
+
//
|
|
1025
|
+
// See kb/framework/plans/static-host-deploy-adapters.md.
|
|
1026
|
+
|
|
1027
|
+
async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
|
|
1028
|
+
let getAdapter
|
|
1029
|
+
try {
|
|
1030
|
+
({ getAdapter } = await import('@uniweb/build/hosts'))
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
say.err('Failed to load host adapter registry from @uniweb/build/hosts.')
|
|
1033
|
+
say.dim(err.message)
|
|
1034
|
+
process.exit(1)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
let adapter
|
|
1038
|
+
try {
|
|
1039
|
+
adapter = getAdapter(hostName)
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
say.err(err.message)
|
|
1042
|
+
say.dim('Set the host in deploy.yml or pass --host=<name>. See `uniweb deploy --help`.')
|
|
1043
|
+
process.exit(1)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (typeof adapter.deploy !== 'function') {
|
|
1047
|
+
say.err(`Host adapter '${hostName}' does not implement a deploy step.`)
|
|
1048
|
+
say.dim(`Build with \`uniweb build --host=${hostName}\` and upload \`dist/\` manually,`)
|
|
1049
|
+
say.dim(`or use a host whose adapter ships a deploy hook (e.g., s3-cloudfront).`)
|
|
1050
|
+
process.exit(1)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const deployConfig = resolved.config || {}
|
|
1054
|
+
const distDir = join(siteDir, 'dist')
|
|
1055
|
+
|
|
1056
|
+
if (dryRun) {
|
|
1057
|
+
say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
|
|
1058
|
+
say.dim(`Site dir : ${siteDir}`)
|
|
1059
|
+
say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
|
|
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)'}`)
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Always rebuild — the static-host flow expects fresh dist/ on every
|
|
1069
|
+
// deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
|
|
1070
|
+
// existing build (mirrors the uniweb-edge flow's escape hatch).
|
|
1071
|
+
const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
|
|
1072
|
+
if (skipBuild) {
|
|
1073
|
+
if (!existsSync(distDir)) {
|
|
1074
|
+
say.err('UNIWEB_SKIP_BUILD is set but dist/ does not exist.')
|
|
1075
|
+
process.exit(1)
|
|
1076
|
+
}
|
|
1077
|
+
say.info('UNIWEB_SKIP_BUILD set — reusing existing dist/.')
|
|
1078
|
+
} else {
|
|
1079
|
+
say.info(`Building site (host: ${adapter.name})…`)
|
|
1080
|
+
console.log('')
|
|
1081
|
+
try {
|
|
1082
|
+
execSync(
|
|
1083
|
+
`node ${JSON.stringify(process.argv[1])} build --bundle --host ${JSON.stringify(adapter.name)}`,
|
|
1084
|
+
{ cwd: siteDir, stdio: 'inherit' }
|
|
1085
|
+
)
|
|
1086
|
+
} catch {
|
|
1087
|
+
say.err('Build failed. See output above.')
|
|
1088
|
+
process.exit(1)
|
|
1089
|
+
}
|
|
1090
|
+
if (!existsSync(distDir)) {
|
|
1091
|
+
say.err('Build did not produce dist/.')
|
|
1092
|
+
process.exit(1)
|
|
1093
|
+
}
|
|
1094
|
+
console.log('')
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Hand off to the adapter. DeployError is the structured shape from
|
|
1098
|
+
// @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
|
|
1099
|
+
try {
|
|
1100
|
+
await adapter.deploy({
|
|
1101
|
+
distDir,
|
|
1102
|
+
deployConfig,
|
|
1103
|
+
env: process.env,
|
|
1104
|
+
log: (m) => console.log(m),
|
|
1105
|
+
})
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
if (err && err.name === 'DeployError') {
|
|
1108
|
+
say.err(err.message)
|
|
1109
|
+
if (err.hint) {
|
|
1110
|
+
console.log('')
|
|
1111
|
+
console.log(err.hint)
|
|
1112
|
+
}
|
|
1113
|
+
process.exit(1)
|
|
1114
|
+
}
|
|
1115
|
+
throw err
|
|
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
|
+
}
|
|
889
1150
|
}
|
|
890
1151
|
|
|
891
1152
|
// ─── site.yml ──────────────────────────────────────────────
|
|
@@ -1008,7 +1269,7 @@ export async function resolveSiteDir(args, verb = 'deploy') {
|
|
|
1008
1269
|
|
|
1009
1270
|
async function fetchLatestRuntime(workerUrl) {
|
|
1010
1271
|
try {
|
|
1011
|
-
const res = await fetch(`${workerUrl}/
|
|
1272
|
+
const res = await fetch(`${workerUrl}/runtime/latest`)
|
|
1012
1273
|
if (!res.ok) return null
|
|
1013
1274
|
const body = await res.json()
|
|
1014
1275
|
return body.version || null
|
|
@@ -1168,7 +1429,7 @@ async function callPublish({ url, token, body }) {
|
|
|
1168
1429
|
|
|
1169
1430
|
/**
|
|
1170
1431
|
* Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
|
|
1171
|
-
* — the shape `POST /
|
|
1432
|
+
* — the shape `POST /foundations` expects in its `files` field.
|
|
1172
1433
|
*/
|
|
1173
1434
|
async function collectFoundationDistFiles(distDir) {
|
|
1174
1435
|
if (!existsSync(distDir)) {
|
|
@@ -1219,13 +1480,37 @@ async function callFoundationUpload({ url, token, body }) {
|
|
|
1219
1480
|
* siteContent is mutated in place so the caller's publish payload picks up
|
|
1220
1481
|
* the rewritten nodes without passing anything back.
|
|
1221
1482
|
*/
|
|
1222
|
-
async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
|
|
1483
|
+
async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
|
|
1223
1484
|
const distAssetsDir = join(siteDir, 'dist', 'assets')
|
|
1224
1485
|
const hasDistAssets = existsSync(distAssetsDir)
|
|
1225
1486
|
|
|
1226
1487
|
// 1. Enumerate local files + read size.
|
|
1227
1488
|
const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
|
|
1228
1489
|
|
|
1490
|
+
// 1a. Content-scan: walk site-content.json (and locale variants) for any
|
|
1491
|
+
// asset references (image/document src/href) and resolve absolute
|
|
1492
|
+
// paths to local files under `dist/` or `public/`. This catches static
|
|
1493
|
+
// assets the author placed in `public/covers/`, `public/images/`, etc.
|
|
1494
|
+
// that the dist/assets walk above misses (vite's image-pipeline only
|
|
1495
|
+
// produces files for refs that go through it). Each resolved file
|
|
1496
|
+
// joins the upload pipeline; the rewrite step at the end maps every
|
|
1497
|
+
// such reference to its CDN identifier so content stays portable
|
|
1498
|
+
// across site delete / template extraction.
|
|
1499
|
+
const contentRefMap = await scanContentForAssetRefs(localeContents, dataFileObjects, siteDir)
|
|
1500
|
+
const seenPaths = new Set(localFiles.map((f) => f.fullPath))
|
|
1501
|
+
for (const [, info] of contentRefMap) {
|
|
1502
|
+
if (seenPaths.has(info.resolvedPath)) continue
|
|
1503
|
+
const ext = (info.filename.split('.').pop() || '').toLowerCase()
|
|
1504
|
+
const st = await stat(info.resolvedPath)
|
|
1505
|
+
localFiles.push({
|
|
1506
|
+
filename: info.filename,
|
|
1507
|
+
fullPath: info.resolvedPath,
|
|
1508
|
+
size: st.size,
|
|
1509
|
+
mime: MIME_BY_EXT[ext] || 'application/octet-stream',
|
|
1510
|
+
})
|
|
1511
|
+
seenPaths.add(info.resolvedPath)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1229
1514
|
// 1a. Favicon — sits at site root, not in dist/assets. Ship it through
|
|
1230
1515
|
// the same pipeline so it ends up at assets.uniweb.app with an
|
|
1231
1516
|
// identifier; config.favicon gets set further down.
|
|
@@ -1345,14 +1630,39 @@ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml,
|
|
|
1345
1630
|
}
|
|
1346
1631
|
|
|
1347
1632
|
// 6. Rewrite each locale's content in place. Image/document nodes whose
|
|
1348
|
-
// src/href references
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1633
|
+
// src/href references an uploaded asset get an info.identifier pointing
|
|
1634
|
+
// to the CDN. Walking every locale means translated content (which
|
|
1635
|
+
// still references the same image files via the source ProseMirror
|
|
1636
|
+
// tree) gets the same rewrite.
|
|
1637
|
+
//
|
|
1638
|
+
// Two lookup paths:
|
|
1639
|
+
// - byOriginalRef: full src/href string → identifier (covers static
|
|
1640
|
+
// public/ assets like `/covers/foo.svg` and dist/-resolved refs)
|
|
1641
|
+
// - byFilename: legacy match for `assets/{filename}` shape — kept
|
|
1642
|
+
// for back-compat with content authored against the old vite-
|
|
1643
|
+
// produced `/assets/...` URLs.
|
|
1352
1644
|
const byFilenameAll = new Map([...reused, ...fresh])
|
|
1645
|
+
const byOriginalRef = new Map()
|
|
1646
|
+
for (const [ref, info] of contentRefMap) {
|
|
1647
|
+
const id = byFilenameAll.get(info.filename)
|
|
1648
|
+
if (id) byOriginalRef.set(ref, id)
|
|
1649
|
+
}
|
|
1353
1650
|
let rewritten = 0
|
|
1354
1651
|
for (const lang of Object.keys(localeContents)) {
|
|
1355
|
-
rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll)
|
|
1652
|
+
rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll, byOriginalRef)
|
|
1653
|
+
}
|
|
1654
|
+
// Data files: walk the JSON tree. Two patterns coexist in collection
|
|
1655
|
+
// payloads:
|
|
1656
|
+
// - Flat fields (e.g. `article.image: "/covers/foo.svg"`) → replace
|
|
1657
|
+
// the string with a resolveAssetCdnUrl(identifier). The runtime
|
|
1658
|
+
// reads these as plain URLs, so rewriting at deploy time is the
|
|
1659
|
+
// simplest path to portability.
|
|
1660
|
+
// - Nested ProseMirror sub-trees (e.g. `article.content`) → use the
|
|
1661
|
+
// existing image/document node rewrite (sets `attrs.info.identifier`).
|
|
1662
|
+
for (const k of Object.keys(dataFileObjects)) {
|
|
1663
|
+
if (dataFileObjects[k] === null) continue
|
|
1664
|
+
rewritten += rewriteFlatAssetUrls(dataFileObjects[k], byOriginalRef)
|
|
1665
|
+
rewritten += rewriteAssetReferences(dataFileObjects[k], byFilenameAll, byOriginalRef)
|
|
1356
1666
|
}
|
|
1357
1667
|
if (rewritten > 0) {
|
|
1358
1668
|
say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
|
|
@@ -1626,45 +1936,69 @@ async function runInPool(items, concurrency, worker) {
|
|
|
1626
1936
|
|
|
1627
1937
|
/**
|
|
1628
1938
|
* Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
|
|
1629
|
-
* `attrs.src` or `attrs.href` references
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1939
|
+
* `attrs.src` or `attrs.href` references an uploaded/reused asset. Sets
|
|
1940
|
+
* `attrs.info.identifier` so semantic-parser resolves the real CDN URL
|
|
1941
|
+
* (and optimized variants) at render time.
|
|
1942
|
+
*
|
|
1943
|
+
* Two lookup paths, in order:
|
|
1944
|
+
* 1. `byOriginalRef` — full src/href string → identifier. Covers static
|
|
1945
|
+
* public/ assets (`/covers/foo.svg`, `/images/foo.png`) and any
|
|
1946
|
+
* content-scan-resolved file. Decouples assets from site lifecycle
|
|
1947
|
+
* (templates can extract content + identifier; assets stay on CDN).
|
|
1948
|
+
* 2. `byFilename` (legacy) — only fires when the path matches the old
|
|
1949
|
+
* `/assets/{filename}` shape. Kept so re-deploys of content authored
|
|
1950
|
+
* against pre-content-scan CLIs still work.
|
|
1632
1951
|
*
|
|
1633
1952
|
* Returns the number of rewrites performed — useful for reporting, and to
|
|
1634
1953
|
* detect "nothing matched" (likely a content-shape mismatch worth flagging).
|
|
1635
1954
|
*/
|
|
1636
|
-
function rewriteAssetReferences(node, byFilename) {
|
|
1955
|
+
function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
|
|
1637
1956
|
let count = 0
|
|
1638
1957
|
const walk = (n) => {
|
|
1639
1958
|
if (!n || typeof n !== 'object') return
|
|
1640
1959
|
if (Array.isArray(n)) { for (const child of n) walk(child); return }
|
|
1641
1960
|
if (n.attrs && typeof n.attrs === 'object') {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
// parseImgBlock which reads info.identifier and fills url.
|
|
1662
|
-
if (n.type === 'image' && n.attrs.role !== 'icon') {
|
|
1663
|
-
n.type = 'ImageBlock'
|
|
1664
|
-
}
|
|
1665
|
-
count++
|
|
1961
|
+
// Prefer full-ref lookup (covers static + dist refs uniformly);
|
|
1962
|
+
// fall back to legacy `assets/{filename}` extraction.
|
|
1963
|
+
let identifier = null
|
|
1964
|
+
let srcMatched = false
|
|
1965
|
+
let hrefMatched = false
|
|
1966
|
+
if (typeof n.attrs.src === 'string' && byOriginalRef.has(n.attrs.src)) {
|
|
1967
|
+
identifier = byOriginalRef.get(n.attrs.src)
|
|
1968
|
+
srcMatched = true
|
|
1969
|
+
} else if (typeof n.attrs.href === 'string' && byOriginalRef.has(n.attrs.href)) {
|
|
1970
|
+
identifier = byOriginalRef.get(n.attrs.href)
|
|
1971
|
+
hrefMatched = true
|
|
1972
|
+
} else {
|
|
1973
|
+
const srcRef = pickAssetRef(n.attrs.src)
|
|
1974
|
+
const hrefRef = pickAssetRef(n.attrs.href)
|
|
1975
|
+
const ref = srcRef || hrefRef
|
|
1976
|
+
if (ref) {
|
|
1977
|
+
identifier = byFilename.get(ref) || null
|
|
1978
|
+
srcMatched = !!srcRef
|
|
1979
|
+
hrefMatched = !srcRef && !!hrefRef
|
|
1666
1980
|
}
|
|
1667
1981
|
}
|
|
1982
|
+
if (identifier) {
|
|
1983
|
+
n.attrs.info = {
|
|
1984
|
+
...(n.attrs.info || {}),
|
|
1985
|
+
identifier,
|
|
1986
|
+
contentType: 'website',
|
|
1987
|
+
viewType: 'profile',
|
|
1988
|
+
}
|
|
1989
|
+
// Clear the local path so the runtime resolves via info.identifier
|
|
1990
|
+
// (→ assets.uniweb.app CDN) instead of requesting a non-existent
|
|
1991
|
+
// file from the site host.
|
|
1992
|
+
if (srcMatched) n.attrs.src = null
|
|
1993
|
+
if (hrefMatched) n.attrs.href = null
|
|
1994
|
+
// Match the Editor shape: plain `image` nodes skip identifier
|
|
1995
|
+
// resolution in older runtimes; `ImageBlock` routes through
|
|
1996
|
+
// parseImgBlock which reads info.identifier and fills url.
|
|
1997
|
+
if (n.type === 'image' && n.attrs.role !== 'icon') {
|
|
1998
|
+
n.type = 'ImageBlock'
|
|
1999
|
+
}
|
|
2000
|
+
count++
|
|
2001
|
+
}
|
|
1668
2002
|
}
|
|
1669
2003
|
for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
|
|
1670
2004
|
}
|
|
@@ -1679,6 +2013,153 @@ function pickAssetRef(v) {
|
|
|
1679
2013
|
return m ? m[1] : null
|
|
1680
2014
|
}
|
|
1681
2015
|
|
|
2016
|
+
/**
|
|
2017
|
+
* Walk every locale's content for `attrs.src` and `attrs.href` strings, and
|
|
2018
|
+
* resolve absolute-path refs (e.g. `/covers/foo.svg`) to local files under
|
|
2019
|
+
* the site root.
|
|
2020
|
+
*
|
|
2021
|
+
* Resolution order per ref:
|
|
2022
|
+
* 1. `dist/{path}` — vite outputs, link-mode collection JSON, etc.
|
|
2023
|
+
* 2. `public/{path}` — static author-placed assets (covers, images).
|
|
2024
|
+
*
|
|
2025
|
+
* Returns Map<originalRef, { resolvedPath, filename }> where:
|
|
2026
|
+
* - `originalRef` — the exact src/href string from content (used as the
|
|
2027
|
+
* lookup key during rewrite).
|
|
2028
|
+
* - `resolvedPath` — absolute path on disk (used for upload).
|
|
2029
|
+
* - `filename` — basename, used as the assets-server upload filename.
|
|
2030
|
+
* Server keys by (siteId, filename); collisions across
|
|
2031
|
+
* paths with the same basename are flagged as warnings.
|
|
2032
|
+
*
|
|
2033
|
+
* Skips:
|
|
2034
|
+
* - Non-string values, refs that don't start with `/`, protocol-relative
|
|
2035
|
+
* refs (`//cdn.example.com/...`), and external URLs.
|
|
2036
|
+
* - Refs starting with `/api/` or `/_` (worker-internal paths, never
|
|
2037
|
+
* local files).
|
|
2038
|
+
* - Nodes already rewritten with `attrs.info.identifier` set (re-deploy).
|
|
2039
|
+
*/
|
|
2040
|
+
async function scanContentForAssetRefs(localeContents, dataFileObjects, siteDir) {
|
|
2041
|
+
const candidates = new Set()
|
|
2042
|
+
for (const lang of Object.keys(localeContents)) {
|
|
2043
|
+
walkContentForAssetRefs(localeContents[lang], candidates)
|
|
2044
|
+
}
|
|
2045
|
+
// Also walk parsed collection JSON files. These contain BOTH ProseMirror-
|
|
2046
|
+
// shaped sub-trees (article.content) AND flat string fields (article.image,
|
|
2047
|
+
// article.cover, etc.). The walker captures both: any string-valued src/
|
|
2048
|
+
// href/image/cover/thumbnail/icon/poster field, plus any string anywhere
|
|
2049
|
+
// that looks like an absolute path with a known media extension.
|
|
2050
|
+
for (const k of Object.keys(dataFileObjects || {})) {
|
|
2051
|
+
if (dataFileObjects[k] !== null) {
|
|
2052
|
+
walkContentForAssetRefs(dataFileObjects[k], candidates)
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const results = new Map()
|
|
2057
|
+
const filenameToRef = new Map() // detect collisions (same basename, different path)
|
|
2058
|
+
for (const ref of candidates) {
|
|
2059
|
+
if (!isResolvableContentRef(ref)) continue
|
|
2060
|
+
const cleanPath = ref.split('?')[0].split('#')[0].slice(1) // drop leading '/'
|
|
2061
|
+
const distCandidate = join(siteDir, 'dist', cleanPath)
|
|
2062
|
+
const publicCandidate = join(siteDir, 'public', cleanPath)
|
|
2063
|
+
let resolvedPath = null
|
|
2064
|
+
if (existsSync(distCandidate)) {
|
|
2065
|
+
try { if ((await stat(distCandidate)).isFile()) resolvedPath = distCandidate } catch {}
|
|
2066
|
+
}
|
|
2067
|
+
if (!resolvedPath && existsSync(publicCandidate)) {
|
|
2068
|
+
try { if ((await stat(publicCandidate)).isFile()) resolvedPath = publicCandidate } catch {}
|
|
2069
|
+
}
|
|
2070
|
+
if (!resolvedPath) continue
|
|
2071
|
+
const filename = resolvedPath.split(sep).pop()
|
|
2072
|
+
const prior = filenameToRef.get(filename)
|
|
2073
|
+
if (prior && prior !== resolvedPath) {
|
|
2074
|
+
// Two different files want the same upload filename — server keys by
|
|
2075
|
+
// filename so the second would clobber the first. Skip + warn rather
|
|
2076
|
+
// than silently overwrite. Caller can rename the file or move one
|
|
2077
|
+
// into a vite-processed path to disambiguate via content hashing.
|
|
2078
|
+
say.warn(
|
|
2079
|
+
`Asset filename collision: "${filename}" exists at multiple paths ` +
|
|
2080
|
+
`(${prior}, ${resolvedPath}). Skipping the second; rename to disambiguate.`
|
|
2081
|
+
)
|
|
2082
|
+
continue
|
|
2083
|
+
}
|
|
2084
|
+
filenameToRef.set(filename, resolvedPath)
|
|
2085
|
+
results.set(ref, { resolvedPath, filename })
|
|
2086
|
+
}
|
|
2087
|
+
return results
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Field names commonly used for media in collection JSON. The walker
|
|
2091
|
+
// collects any absolute-path string under these keys as a potential asset
|
|
2092
|
+
// reference. ProseMirror image/link nodes are caught separately via attrs.
|
|
2093
|
+
const FLAT_ASSET_FIELDS = new Set([
|
|
2094
|
+
'src', 'href', 'image', 'cover', 'thumbnail', 'icon', 'poster', 'logo',
|
|
2095
|
+
'avatar', 'photo', 'banner', 'background',
|
|
2096
|
+
])
|
|
2097
|
+
|
|
2098
|
+
function walkContentForAssetRefs(node, refs) {
|
|
2099
|
+
if (!node || typeof node !== 'object') return
|
|
2100
|
+
if (Array.isArray(node)) { for (const child of node) walkContentForAssetRefs(child, refs); return }
|
|
2101
|
+
if (node.attrs && typeof node.attrs === 'object') {
|
|
2102
|
+
// Skip nodes already rewritten in a prior deploy — those have an
|
|
2103
|
+
// identifier and the runtime resolves them through the CDN already.
|
|
2104
|
+
if (!node.attrs.info?.identifier) {
|
|
2105
|
+
if (typeof node.attrs.src === 'string') refs.add(node.attrs.src)
|
|
2106
|
+
if (typeof node.attrs.href === 'string') refs.add(node.attrs.href)
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
// Flat fields: collection-shaped objects (e.g. an article record) often
|
|
2110
|
+
// carry media URLs as plain string fields rather than ProseMirror nodes.
|
|
2111
|
+
// Capture absolute-path values under known keys.
|
|
2112
|
+
for (const [k, v] of Object.entries(node)) {
|
|
2113
|
+
if (typeof v === 'string' && FLAT_ASSET_FIELDS.has(k) && isResolvableContentRef(v)) {
|
|
2114
|
+
refs.add(v)
|
|
2115
|
+
} else if (typeof v === 'object') {
|
|
2116
|
+
walkContentForAssetRefs(v, refs)
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Walk an arbitrary JSON tree and replace any string equal to a key in
|
|
2123
|
+
* `byOriginalRef` (and not already a CDN URL) with the asset's CDN URL.
|
|
2124
|
+
* Used for collection JSON files where image refs are flat string fields
|
|
2125
|
+
* (e.g. `article.image: "/covers/foo.svg"`) rather than ProseMirror nodes.
|
|
2126
|
+
*
|
|
2127
|
+
* Returns the number of replacements performed.
|
|
2128
|
+
*/
|
|
2129
|
+
function rewriteFlatAssetUrls(node, byOriginalRef) {
|
|
2130
|
+
let count = 0
|
|
2131
|
+
const walk = (n, parent, key) => {
|
|
2132
|
+
if (n == null) return
|
|
2133
|
+
if (typeof n === 'string') {
|
|
2134
|
+
const id = byOriginalRef.get(n)
|
|
2135
|
+
if (id && parent != null && key != null) {
|
|
2136
|
+
parent[key] = resolveAssetCdnUrl(id)
|
|
2137
|
+
count++
|
|
2138
|
+
}
|
|
2139
|
+
return
|
|
2140
|
+
}
|
|
2141
|
+
if (typeof n !== 'object') return
|
|
2142
|
+
if (Array.isArray(n)) {
|
|
2143
|
+
for (let i = 0; i < n.length; i++) walk(n[i], n, i)
|
|
2144
|
+
return
|
|
2145
|
+
}
|
|
2146
|
+
for (const [k, v] of Object.entries(n)) walk(v, n, k)
|
|
2147
|
+
}
|
|
2148
|
+
walk(node, null, null)
|
|
2149
|
+
return count
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function isResolvableContentRef(ref) {
|
|
2153
|
+
if (typeof ref !== 'string' || !ref) return false
|
|
2154
|
+
// Absolute-path only — relative paths (`./foo`, `foo`) are content-author
|
|
2155
|
+
// shorthand handled elsewhere; URLs (`http://`, `//cdn`) never resolve to
|
|
2156
|
+
// local files; worker-internal paths (`/api/`, `/_`) aren't asset content.
|
|
2157
|
+
if (!ref.startsWith('/')) return false
|
|
2158
|
+
if (ref.startsWith('//')) return false
|
|
2159
|
+
if (ref.startsWith('/api/') || ref.startsWith('/_')) return false
|
|
2160
|
+
return true
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1682
2163
|
// ─── Loopback listener (review path) ───────────────────────
|
|
1683
2164
|
|
|
1684
2165
|
/**
|
package/src/commands/export.js
CHANGED
|
@@ -17,6 +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> Pick a host adapter for postBuild
|
|
21
|
+
* (e.g. cloudflare-pages, s3-cloudfront,
|
|
22
|
+
* github-pages, generic-static).
|
|
23
|
+
* Default: cloudflare-pages.
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import { execSync } from 'node:child_process'
|
|
@@ -42,14 +46,31 @@ const say = {
|
|
|
42
46
|
export async function exportSite(args = []) {
|
|
43
47
|
const siteDir = await resolveSiteDir(args, 'export')
|
|
44
48
|
|
|
45
|
-
// Pass through --no-prerender
|
|
46
|
-
// export`
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
+
// Pass through --no-prerender and --host. Everything else is ignored.
|
|
50
|
+
// `uniweb export` stays low-flag: the user picks the destination host
|
|
51
|
+
// themselves outside the CLI, so there's nothing to configure beyond
|
|
52
|
+
// what `uniweb build --bundle` already exposes.
|
|
49
53
|
const noPrerender = args.includes('--no-prerender')
|
|
50
54
|
const buildArgs = ['build', '--bundle']
|
|
51
55
|
if (noPrerender) buildArgs.push('--no-prerender')
|
|
52
56
|
|
|
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)
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
say.info('Exporting site (vite build → dist/)…')
|
|
54
75
|
console.log('')
|
|
55
76
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-05-
|
|
3
|
+
"generatedAt": "2026-05-05T18:36:54.411Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/utils/config.js
CHANGED
|
@@ -14,9 +14,15 @@ import { filterCmd } from './pm.js'
|
|
|
14
14
|
|
|
15
15
|
// ── Platform URLs ──────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
// Production defaults — regular users get these out of the box
|
|
17
|
+
// Production defaults — regular users get these out of the box.
|
|
18
|
+
// REGISTRY hosts platform operations (publish, foundations, runtime, admin):
|
|
19
|
+
// moved to hosting.uniweb.app in the CDN migration (Phase 4c, 2026-05-04).
|
|
20
|
+
// BACKEND hosts the PHP user-facing surface (login, account, orgs, billing,
|
|
21
|
+
// publish-authorize): owned by the v4 single-domain plan
|
|
22
|
+
// (kb/platform/plans/uniweb-domain-plan-v4.md), which will move it to
|
|
23
|
+
// uniweb.app/api/* when v4 ships. Until then, it stays at hub.uniweb.app.
|
|
18
24
|
const PRODUCTION_BACKEND_URL = 'https://hub.uniweb.app'
|
|
19
|
-
const PRODUCTION_REGISTRY_URL = 'https://
|
|
25
|
+
const PRODUCTION_REGISTRY_URL = 'https://hosting.uniweb.app'
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Read ~/.uniweb/config.json for persistent URL overrides.
|
|
@@ -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
|
+
}
|