uniweb 0.12.34 → 0.12.36

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.
@@ -1,67 +1,46 @@
1
1
  /**
2
- * Deploy Command
2
+ * Deploy Command — ship a site to its resolved target.
3
3
  *
4
- * Deploys a site. Host is determined by the resolved deploy.yml target
5
- * (or `--target <name>` / `--host <name>` flags). The default is `uniweb`:
4
+ * `uniweb deploy` resolves WHERE a site goes from deploy.yml (+ `--host` /
5
+ * `--target`) and ships it there:
6
+ * - THIRD-PARTY host (`s3-cloudfront`, `cloudflare-pages`, `github-pages`,
7
+ * `generic-static`, …): build `dist/` in bundle mode and hand it to the
8
+ * host adapter for upload + invalidation.
9
+ * - UNIWEB hosting target (an explicit `--host=uniweb`, or a `uniweb` target
10
+ * in deploy.yml): DELEGATE to `uniweb publish` — the smart path (sync +
11
+ * dynamic hosting, brings the foundation along). So deploy.yml stays one
12
+ * actionable "where this site deploys" record, uniweb included.
6
13
  *
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.
14
+ * `uniweb publish` is the canonical direct verb for Uniweb hosting (reach for it
15
+ * by default); `uniweb export` writes a self-contained artifact you upload
16
+ * yourself.
10
17
  *
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.
15
- *
16
- * For static-host artifacts WITHOUT upload, see `uniweb export`. To publish a
17
- * site that already lives on the backend as a synced draft, see `uniweb release`.
18
- *
19
- * Uniweb-host flow (deployToUniwebBackend) — the composite deploy = build → ball →
20
- * media → push → publish:
21
- * 1. Resolve the site dir + deploy.yml target; discover() the backend (GET
22
- * /dev/config) and resolve the runtime (`site.yml::runtime` if pinned, else the
23
- * backend's highest installed; fail closed if neither resolves).
24
- * 2. Build the site data (link mode): site-content.json (+ per-locale variants),
25
- * collection data, search indexes, processed assets.
26
- * 3. Partition collections by schema presence: schema-less → the static-data ball;
27
- * schema-backed → typed folder entities on the push lane.
28
- * 4. Upload the site's local media (entity refs + the ball's refs, one deduped set) →
29
- * each site-root ref's backend serve URL; rewrite the ball with it, then upload the
30
- * rewritten ball (content-addressed → `info.data_bundle`).
31
- * 5. Push — the SAME two-lane sync `uniweb push` uses (site-content with
32
- * `info.data_bundle` stamped + media refs rewritten, then the folder + records) —
33
- * over the send-only-changed cache; the backend mints/round-trips the site uuid.
34
- * 6. Publish — make the just-pushed composite live; the backend returns the serve URL.
18
+ * Host resolution:
19
+ * 1. --target <name> picks a target from deploy.yml (full config)
20
+ * 2. deploy.yml's `default:` target when no flag is given
21
+ * 3. with no deploy.yml at all, NO host is chosen → deploy prompts for a
22
+ * third-party adapter (interactive) rather than assuming Uniweb;
23
+ * non-interactive an actionable error pointing at `publish` / `--host`
24
+ * 4. --host <name> is a one-off override (does NOT persist to deploy.yml)
35
25
  *
36
26
  * Usage:
37
- * uniweb deploy Build + deploy to the resolved target
38
- * uniweb deploy --dry-run Resolve everything; POST nothing
39
- * uniweb deploy --target <name> Pick a target from deploy.yml (default: its `default:`)
40
- * uniweb deploy --host <name> One-off host override (not persisted to deploy.yml)
27
+ * uniweb deploy --host <name> Build bundle-mode dist/ + hand to the host adapter
28
+ * uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
29
+ * uniweb deploy --target <name> Pick a target from deploy.yml
30
+ * uniweb deploy --dry-run Resolve everything; upload nothing
41
31
  * uniweb deploy --no-save Skip the deploy.yml lastDeploy auto-save
42
- * uniweb deploy --backend <url> Override the backend origin
43
32
  *
44
- * Backend: BackendClient. Origin from --backend/--registry > UNIWEB_REGISTER_URL
45
- * > the default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
46
- *
47
- * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/ (static-host flow).
33
+ * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
48
34
  */
49
35
 
50
36
  import { existsSync } from 'node:fs'
51
- import { readFile } from 'node:fs/promises'
52
37
  import { resolve, join } from 'node:path'
53
38
  import { execSync } from 'node:child_process'
54
- import yaml from 'js-yaml'
55
39
 
56
- import { loadDeployYml, resolveTarget, recordLastDeploy, assembleDataBall, collectBallAssets, rewriteBallAssets } from '@uniweb/build/site'
40
+ import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
57
41
  import { promptForHost } from '../utils/host-prompt.js'
58
42
  import { readFlagValue } from '../utils/args.js'
59
43
  import { parseBoolEnv } from '../utils/env.js'
60
- import { BackendClient } from '../backend/client.js'
61
- import { emitSyncPackages } from '@uniweb/build/uwx'
62
- import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
63
- import { uploadDataBundle } from '../backend/data-bundle.js'
64
- import { uploadSiteMedia } from '../backend/site-media.js'
65
44
 
66
45
  import {
67
46
  findWorkspaceRoot,
@@ -71,69 +50,6 @@ import {
71
50
  } from '../utils/workspace.js'
72
51
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
73
52
 
74
- const FOUNDATION_POLICIES = new Set(['exact', 'auto-patch', 'auto-minor'])
75
-
76
- /**
77
- * Parse the `foundation:` field from site.yml into a normalized shape.
78
- *
79
- * Accepts:
80
- * - string: '@uniweb/votiverse@0.1.1'
81
- * - object: { ref: '@uniweb/votiverse@0.1.1', policy?: ..., pinned?: true }
82
- *
83
- * Returns one of:
84
- * - { error: 'description of what's wrong' }
85
- * - { normalized, policy?, pinned } where `normalized` is whichever
86
- * shape we received (string or { ref, policy?, pinned? }) — the Worker
87
- * accepts both. `policy`/`pinned` are also returned individually so
88
- * the CLI can print friendly diagnostics.
89
- *
90
- * Validation rules (mirrors publish.js::parseFoundationConfig):
91
- * - `policy` must be one of 'exact', 'auto-patch', 'auto-minor'
92
- * - `pinned: true` + `policy: not-exact` is rejected as conflicting
93
- */
94
- function parseSiteFoundation(input) {
95
- if (typeof input === 'string') {
96
- return { normalized: input, policy: null, pinned: false }
97
- }
98
- if (!input || typeof input !== 'object') {
99
- return { error: 'foundation must be a string or object' }
100
- }
101
-
102
- // Object form must carry `ref`; everything else is metadata.
103
- if (!input.ref || typeof input.ref !== 'string') {
104
- return { error: 'foundation.ref is required when using object form' }
105
- }
106
- if (!/^@[a-z0-9_-]+\/[a-z0-9_-]+@.+$/.test(input.ref)) {
107
- return {
108
- error: `foundation.ref does not match @namespace/name@version: '${input.ref}'`,
109
- }
110
- }
111
-
112
- let policy = null
113
- if (input.policy != null) {
114
- if (!FOUNDATION_POLICIES.has(input.policy)) {
115
- return {
116
- error: `foundation.policy must be one of 'exact', 'auto-patch', 'auto-minor' (got '${input.policy}')`,
117
- }
118
- }
119
- policy = input.policy
120
- }
121
- const pinned = input.pinned === true
122
-
123
- if (pinned && policy && policy !== 'exact') {
124
- return {
125
- error: `foundation: 'pinned: true' conflicts with policy '${policy}'. ` +
126
- `Use either 'pinned: true' or 'policy: \"exact\"' (they're equivalent), or drop one.`,
127
- }
128
- }
129
-
130
- return {
131
- normalized: { ref: input.ref, ...(policy ? { policy } : {}), ...(pinned ? { pinned: true } : {}) },
132
- policy: pinned ? 'exact' : policy,
133
- pinned,
134
- }
135
- }
136
-
137
53
  const c = {
138
54
  reset: '\x1b[0m',
139
55
  bold: '\x1b[1m',
@@ -156,24 +72,12 @@ const say = {
156
72
  export async function deploy(args = []) {
157
73
  const dryRun = args.includes('--dry-run')
158
74
  const siteDir = await resolveSiteDir(args)
159
- // Read site.yml — declares the foundation (required) and optionally the
160
- // site.id / site.handle from prior deploys.
161
- const siteYmlPath = join(siteDir, 'site.yml')
162
- const siteYml = await readSiteYml(siteYmlPath)
163
75
 
164
- // Host dispatch.
165
- //
166
- // Resolution order:
167
- // 1. --target <name> picks a target from deploy.yml (full config:
168
- // host + adapter-specific fields)
169
- // 2. deploy.yml's `default:` target is used when no flag is given
170
- // 3. With no deploy.yml at all, the implicit default is host: 'uniweb'
171
- // 4. --host <name> is a one-off override of the resolved target's host
172
- // and does NOT persist on success (see saveDeployTarget below).
173
- //
174
- // The default flow (`uniweb`) requires a `foundation:` declaration;
175
- // static-host deploys don't, so this branch comes BEFORE the foundation
176
- // check.
76
+ // Host dispatch. Resolution order:
77
+ // 1. --target <name> picks a target from deploy.yml
78
+ // 2. deploy.yml's `default:` target when no flag is given
79
+ // 3. with no deploy.yml, the implicit default is host: 'uniweb'
80
+ // 4. --host <name> is a one-off override (does not persist on success)
177
81
  const targetFromFlag = readFlagValue(args, '--target')
178
82
  let hostFromFlag = readFlagValue(args, '--host')
179
83
  const noSave = args.includes('--no-save')
@@ -202,291 +106,61 @@ export async function deploy(args = []) {
202
106
  process.exit(1)
203
107
  }
204
108
  }
205
- const host = hostFromFlag || resolved.host
206
- const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
207
- // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override
208
- // (we don't want a one-off experiment to rewrite the file).
209
- const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
210
-
211
- if (host !== 'uniweb') {
212
- await deployStaticHost(siteDir, host, resolved, {
213
- dryRun,
214
- autoSave,
215
- hostOverridden,
216
- })
217
- return
218
- }
219
-
220
- if (!siteYml.foundation) {
221
- say.err('site.yml is missing `foundation`.')
222
- say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
223
- process.exit(1)
224
- }
225
-
226
- // Foundation may be string or object form (see site.yml docs).
227
- const fnd = parseSiteFoundation(siteYml.foundation)
228
- if (fnd.error) {
229
- say.err(`site.yml: ${fnd.error}`)
230
- process.exit(1)
231
- }
232
- // `foundation` is the on-the-wire shape we forward to PHP authorize +
233
- // Worker publish. PHP only inspects the namespace via the ref string;
234
- // it doesn't care about policy/pinned, so the object form passes through.
235
- // The Worker (publish.js::parseFoundationConfig) handles both shapes.
236
- let foundation = fnd.normalized
237
- if (fnd.policy && fnd.policy !== 'auto-patch') {
238
- say.dim(`Foundation policy: ${fnd.policy}${fnd.pinned ? ' (pinned)' : ''}`)
239
- } else if (fnd.pinned) {
240
- say.dim('Foundation policy: exact (pinned)')
241
- }
242
-
243
- // Uniweb hosting → the new backend's /dev/deploy delivery lane (BackendClient):
244
- // one authed POST, no PHP authorize, no Worker publish, no JWT. Backend chosen by
245
- // origin only; capabilities + installed runtimes discovered via GET /dev/config.
246
- // Foundation/runtime resolution, payload assembly, the POST, and the deploy.yml
247
- // uuid round-trip all live in deployToUniwebBackend. The legacy PHP-authorize +
248
- // Worker-publish flow below is retired by this routing (excised on cutover).
249
- await deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave })
250
- return
251
- }
252
-
253
- // ─── Uniweb-backend deploy (the /dev/deploy delivery lane) ────────────────
254
- //
255
- // Hosts a file-built site on the Uniweb backend through BackendClient: one authed
256
- // POST /dev/deploy carrying the deploy payload `build-site-data.js` produces. The
257
- // login bearer authorizes (the account IS the authorization) — no PHP authorize,
258
- // no Worker publish, no JWT, no asset-presign dance. Backend is chosen by origin
259
- // only (--backend/--registry > UNIWEB_REGISTER_URL > default); everything else is
260
- // discovered via GET /dev/config (capabilities + installed runtimes). Replaces the
261
- // legacy PHP+Worker flow in deploy() above.
262
-
263
- async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave }) {
264
- const client = new BackendClient({
265
- originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
266
- token: readFlagValue(args, '--token'),
267
- args,
268
- command: 'Deploying',
269
- })
270
-
271
- const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
272
- const asOrg = readFlagValue(args, '--as-org')
273
-
274
- // Anonymous capability handshake (cached). The composite deploy ends in a publish,
275
- // so confirm that lane is offered (the push/sync lanes are the backend's baseline).
276
- const config = await client.discover()
277
- if (config?.delivery && config.delivery.publish === false) {
278
- say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
279
- process.exit(1)
280
- }
281
-
282
- // Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
283
- // version the backend reports installed; else fail closed with a clear
284
- // precondition error (better than serving a site with no runtime).
285
- const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
286
- if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
287
- say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
288
- say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
289
- process.exit(1)
290
- }
291
- const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
292
- if (!runtimeVersion) {
293
- say.err('Could not resolve a runtime version.')
294
- say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
295
- process.exit(1)
296
- }
297
-
298
- if (dryRun) {
299
- say.info('Dry run — would deploy to the Uniweb backend as a composite (ball → push → publish):')
300
- say.dim(`Backend : ${client.origin}`)
301
- say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
302
- say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
303
- say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
304
- return
305
- }
306
-
307
- // Build (link mode): emits dist/data/*, dist/_search/*, dist/assets/*, and
308
- // dist/site-content.json. Spawn the SAME CLI binary so the inner build can't resolve
309
- // to a different installed version.
310
- say.info('Building site…')
311
- console.log('')
312
- execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
313
- cwd: siteDir,
314
- stdio: 'inherit',
315
- env: process.env,
316
- })
317
- console.log('')
318
-
319
- const distDir = join(siteDir, 'dist')
320
- const contentPath = join(distDir, 'site-content.json')
321
- if (!existsSync(contentPath)) {
322
- say.err('Build did not produce dist/site-content.json')
323
- process.exit(1)
324
- }
325
-
326
- // Non-local @std/registry Model schemas resolve through the backend (same as push).
327
- const resolveModel = makeModelResolver({ client, offline: false })
328
-
329
- // 1. Partition the collections by schema presence. A first emit reads `schemaless`
330
- // — the collections with no data schema, delivered statically via the ball. Its
331
- // packages are discarded (deploy is not a hot path; the cheap clarity beats a
332
- // schemaless-only fast path, a later optimization).
333
- let probe
334
- try {
335
- probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
336
- } catch (err) {
337
- say.err(`Could not build the sync package: ${err.message}`)
338
- process.exit(1)
339
- }
340
- const schemalessNames = (probe.schemaless || []).map((col) => col.name)
341
- const localAssets = probe.localAssets || [] // entity-content site-root media refs
342
-
343
- // 2. Assemble the static-data ball (schema-less collection data + the search index) —
344
- // BEFORE uploading it, because its schema-less records can carry local media too,
345
- // which we upload + rewrite to serve URLs exactly like entity content (the backend
346
- // serves a serve_url in the ball identically — it unwraps the ball verbatim).
347
- let ball = await assembleDataBall(distDir, schemalessNames)
348
- const ballAssets = collectBallAssets(ball)
349
-
350
- // 2b. Upload ALL local media (entity refs + ball refs) on one asset lane → the
351
- // ref→serveUrl map. The same map rewrites the entity content (assetRewrite, real
352
- // emit below) AND the ball (here, before it's uploaded). Co-located refs were
353
- // warned + skipped by the producer; a missing file is skipped here (warned).
354
- let assetRewrite = null
355
- const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
356
- if (mediaRefs.length) {
357
- say.info('Uploading media…')
358
- try {
359
- const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
360
- onProgress: (m) => say.dim(` ${m}`),
361
- warn: (m) => say.dim(`! ${m}`),
362
- })
363
- if (Object.keys(map).length) assetRewrite = map
364
- if (ballAssets.length) ball = rewriteBallAssets(ball, map) // swap the ball's local refs → serve URLs
365
- say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
366
- } catch (err) {
367
- say.err(`Media upload failed: ${err.message}`)
109
+ let host = hostFromFlag || resolved.host
110
+
111
+ // A Uniweb-hosting target is `publish`'s flow. When the user EXPLICITLY chose
112
+ // uniweb (a `--host=uniweb`, or a `uniweb` target in deploy.yml), DELEGATE to
113
+ // `uniweb publish` so deploy.yml stays one actionable record. When NO host was
114
+ // chosen (the implicit default with no deploy.yml), don't assume uniweb:
115
+ // prompt for a third-party adapter (interactive) or point at publish / --host
116
+ // (non-interactive). promptForHost lists only third-party adapters.
117
+ if (host === 'uniweb') {
118
+ const explicitUniweb = hostFromFlag === 'uniweb' || (resolved.fromFile && resolved.host === 'uniweb')
119
+ if (explicitUniweb) {
120
+ say.info('Uniweb hosting target → running `uniweb publish`.')
121
+ console.log('')
122
+ // publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
123
+ // /--token pass straight through.
124
+ const { publish } = await import('./publish.js')
125
+ const result = await publish(args)
126
+ process.exit(result?.exitCode ?? 0)
127
+ }
128
+ if (isNonInteractive(args)) {
129
+ say.err('`uniweb deploy` needs a host. For Uniweb hosting use `uniweb publish`; for a third-party host pass `--host=<adapter>`.')
130
+ console.log('')
131
+ say.dim('`uniweb publish` Uniweb hosting (sync + dynamic hosting; brings the foundation along)')
132
+ say.dim('`uniweb deploy --host=…` Third-party host (s3-cloudfront, cloudflare-pages, github-pages, generic-static)')
133
+ say.dim('`uniweb export` Self-contained dist/ artifact you upload anywhere')
368
134
  process.exit(1)
369
135
  }
370
- }
371
-
372
- // 2c. Upload the (media-rewritten) ball. `data_bundle` is its content-addressed serve
373
- // URL; omitted when there is nothing static to deliver.
374
- let dataBundle
375
- if (ball) {
376
- say.info('Uploading data bundle…')
136
+ say.info('`uniweb deploy` ships to a third-party host. (For Uniweb hosting, run `uniweb publish`.)')
377
137
  try {
378
- dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
138
+ host = await promptForHost({ args })
379
139
  } catch (err) {
380
- say.err(`Data bundle upload failed: ${err.message}`)
140
+ say.err(err.message)
381
141
  process.exit(1)
382
142
  }
383
- say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
384
- }
385
-
386
- // 3. Push the site (content + folder) over the send-only-changed cache — the SAME
387
- // two-lane submission `uniweb push` uses — stamping info.data_bundle on the
388
- // site-content entity and rewriting local media refs to their backend serve URLs.
389
- const priorHashes = readSyncCache(siteDir)
390
- let pkg
391
- try {
392
- pkg = await emitSyncPackages(siteDir, {
393
- ...(foundationDir ? { foundationDir } : {}),
394
- resolveModel,
395
- priorHashes,
396
- ...(dataBundle ? { injectInfo: { data_bundle: dataBundle } } : {}),
397
- ...(assetRewrite ? { assetRewrite } : {}),
398
- })
399
- } catch (err) {
400
- say.err(`Could not build the sync package: ${err.message}`)
401
- process.exit(1)
402
- }
403
- for (const w of pkg.warnings) say.dim(`! ${w}`)
404
- const report = {
405
- info: (m) => say.info(m),
406
- note: (m) => say.dim(m),
407
- error: (m) => say.err(m),
408
- dim: (s) => `${c.dim}${s}${c.reset}`,
409
- }
410
- const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
411
- if (pushResult.exitCode !== 0) process.exit(pushResult.exitCode)
412
- const siteUuid = pushResult.boundSiteUuid
413
- if (!siteUuid) {
414
- say.err('Push did not yield a site uuid — cannot publish.')
415
- process.exit(1)
416
143
  }
417
144
 
418
- // 4. Publish: make the just-pushed composite live (its current backend state).
419
- const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
420
- const languages = extractLanguages(siteContent)
421
- say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
422
- let pubRes
423
- try {
424
- pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
425
- } catch (err) {
426
- say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
427
- say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
428
- process.exit(1)
429
- }
430
- if (!pubRes.ok) {
431
- say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
432
- if (pubRes.status === 401 || pubRes.status === 403) {
433
- say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
434
- }
435
- const body = await pubRes.text().catch(() => '')
436
- if (body) say.dim(body.slice(0, 800))
437
- process.exit(1)
438
- }
439
- let result
440
- try { result = await pubRes.json() } catch { result = {} }
441
- const serveUrl = absolutizeServeUrl(client.origin, result.url)
145
+ // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override (we don't
146
+ // want a one-off experiment to rewrite the file). A host picked interactively
147
+ // for a bare `deploy` is NOT an override — we DO want to remember it.
148
+ const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
149
+ const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
442
150
 
443
- // Persist deploy memory. One identity: site.yml::$uuid (the push uuid) — no separate
444
- // deploy uuid. recordLastDeploy touches only lastDeploy.<target>.
445
- await persistLastDeploy(siteDir, {
446
- targetName: resolved.targetName,
447
- targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
151
+ await deployStaticHost(siteDir, host, resolved, {
152
+ dryRun,
448
153
  autoSave,
449
- lastDeploy: {
450
- at: new Date().toISOString(),
451
- host: 'uniweb',
452
- backend: client.origin,
453
- siteUuid,
454
- url: serveUrl,
455
- foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
456
- runtime: runtimeVersion,
457
- locales: Array.isArray(result.locales) ? result.locales : languages,
458
- },
154
+ hostOverridden,
459
155
  })
460
-
461
- console.log('')
462
- say.ok(`Deployed ${c.bold}${siteUuid}${c.reset}`)
463
- if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
464
- }
465
-
466
- // Pick the highest runtime from the backend's installed list. localeCompare with
467
- // numeric ordering puts '0.8.16' above '0.8.9' and orders the synthetic dev tags
468
- // deterministically. Null when the list is empty.
469
- function pickHighestRuntime(installed) {
470
- if (!Array.isArray(installed) || installed.length === 0) return null
471
- return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
472
- }
473
-
474
- // The deploy response `url` is the serve path. When origin-relative (the self-serve
475
- // default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
476
- // link is clickable; absolute URLs pass through unchanged.
477
- function absolutizeServeUrl(origin, url) {
478
- if (!url || typeof url !== 'string') return null
479
- if (/^https?:\/\//.test(url)) return url
480
- return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
481
156
  }
482
157
 
483
158
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
484
159
  //
485
- // Distinct from the uniweb-edge flow above. Picked when the resolved
486
- // deploy.yml target (or --host override) names a static-host adapter
487
- // registered in @uniweb/build/hosts. Always runs `uniweb build` (bundle
488
- // mode + prerender) first, then hands dist/ to the adapter's deploy hook
489
- // for upload + invalidation.
160
+ // Picked when the resolved deploy.yml target (or --host override) names a
161
+ // static-host adapter registered in @uniweb/build/hosts. Always runs
162
+ // `uniweb build` (bundle mode + prerender) first, then hands dist/ to the
163
+ // adapter's deploy hook for upload + invalidation.
490
164
 
491
165
  async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
492
166
  let getAdapter
@@ -531,7 +205,7 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
531
205
 
532
206
  // Always rebuild — the static-host flow expects fresh dist/ on every
533
207
  // deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
534
- // existing build (mirrors the uniweb-edge flow's escape hatch).
208
+ // existing build.
535
209
  const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
536
210
  if (skipBuild) {
537
211
  if (!existsSync(distDir)) {
@@ -613,25 +287,11 @@ async function persistLastDeploy(siteDir, opts) {
613
287
  }
614
288
  }
615
289
 
616
- // ─── site.yml ──────────────────────────────────────────────
617
-
618
- async function readSiteYml(path) {
619
- if (!existsSync(path)) return {}
620
- try {
621
- const parsed = yaml.load(await readFile(path, 'utf8'))
622
- return parsed && typeof parsed === 'object' ? parsed : {}
623
- } catch (err) {
624
- say.err(`Could not parse ${path}: ${err.message}`)
625
- process.exit(1)
626
- }
627
- }
628
-
629
- // ─── Resolve site dir + runtime ────────────────────────────
290
+ // ─── Resolve site dir ──────────────────────────────────────
630
291
 
631
- // Exported so `uniweb export` (commands/export.js) can reuse the same
632
- // site-discovery logic without duplicating it. `verb` is the command
633
- // being run ("deploy" or "export"); it appears in the error messages
634
- // so the user gets accurate guidance.
292
+ // Exported so `uniweb export` / `uniweb publish` / `uniweb status` reuse the
293
+ // same site-discovery logic. `verb` is the command being run; it appears in
294
+ // the error messages so the user gets accurate guidance.
635
295
  export async function resolveSiteDir(args, verb = 'deploy') {
636
296
  const cwd = process.cwd()
637
297
  const prefix = getCliPrefix()
@@ -664,17 +324,28 @@ export async function resolveSiteDir(args, verb = 'deploy') {
664
324
  say.err('No site found in this workspace.')
665
325
  if (verb === 'export') {
666
326
  say.dim('`export` produces a self-contained dist/ artifact for third-party hosting.')
327
+ } else if (verb === 'deploy') {
328
+ say.dim('`deploy` ships a built site to a third-party host (use `uniweb publish` for Uniweb hosting).')
667
329
  } else {
668
- say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
330
+ say.dim(`\`${verb}\` operates on a site.`)
669
331
  }
670
332
  process.exit(1)
671
333
  }
672
334
 
673
- // ─── Content helpers ───────────────────────────────────────
674
-
675
- function extractLanguages(siteContent) {
676
- const langs = siteContent?.config?.languages
677
- if (!Array.isArray(langs) || langs.length === 0) return ['en']
678
- // Three accepted shapes: plain `'en'`, Editor `{ value, label }`, site.yml `{ code, label }`.
679
- return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
335
+ // The site's deploy.yml-bound backend origin (for the resolved `uniweb` target),
336
+ // or null when there's no deploy.yml, the target isn't Uniweb hosting, or no
337
+ // backend was recorded. Site verbs pass this to BackendClient as `siteBackend`
338
+ // so a site stays bound to the backend it publishes to — deploy.yml is the
339
+ // record of *where* a site is deployed (the 98% case is uniweb.app, but a B2B
340
+ // university backend is just a `backend:` on the target). Sits below --backend
341
+ // and UNIWEB_REGISTER_URL but above the logged-in session in the resolution
342
+ // ladder (see resolveBackendOrigin). Best-effort: any read/parse error → null.
343
+ export async function resolveSiteBackend(siteDir) {
344
+ try {
345
+ const deployYml = await loadDeployYml(siteDir)
346
+ const resolved = resolveTarget(deployYml, null)
347
+ return resolved.host === 'uniweb' ? (resolved.config?.backend || null) : null
348
+ } catch {
349
+ return null
350
+ }
680
351
  }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Produces a self-contained, vite-built site artifact in `dist/` for
5
5
  * hosting on a third-party CDN (Netlify, Vercel, GitHub Pages, S3 +
6
- * CloudFront, etc.). Does NOT upload anywhere — that's `uniweb deploy`.
6
+ * CloudFront, etc.). Does NOT upload anywhere — `uniweb deploy --host`
7
+ * uploads to a third-party host; `uniweb publish` ships to Uniweb hosting.
7
8
  *
8
9
  * The `dist/` output bundles the runtime + foundation + content into
9
10
  * concatenated packaging, with a vite-built `index.html` + `entry.js` +
@@ -12,7 +13,8 @@
12
13
  * Internally this is `uniweb build --bundle` plus user guidance for the
13
14
  * upload step. The `--link` / `--bundle` flag pair is internal-only
14
15
  * vocabulary now (Phase 2 of the CLI ergonomics overhaul); users see
15
- * `uniweb deploy` (uniweb-edge) and `uniweb export` (third-party host).
16
+ * `uniweb publish` (Uniweb hosting), `uniweb deploy --host` (third-party),
17
+ * and `uniweb export` (self-contained artifact).
16
18
  *
17
19
  * Usage:
18
20
  * uniweb export Produce dist/ for static hosting
@@ -102,5 +104,5 @@ export async function exportSite(args = []) {
102
104
  console.log(` ${c.dim}Vercel:${c.reset} ${c.cyan}vercel --prod${c.reset}`)
103
105
  console.log(` ${c.dim}S3:${c.reset} ${c.cyan}aws s3 sync dist/ s3://your-bucket/${c.reset}`)
104
106
  console.log('')
105
- console.log(` ${c.dim}For Uniweb-hosted sites instead, use ${c.reset}${c.cyan}uniweb deploy${c.reset}${c.dim}.${c.reset}`)
107
+ console.log(` ${c.dim}For Uniweb hosting instead, use ${c.reset}${c.cyan}uniweb publish${c.reset}${c.dim}.${c.reset}`)
106
108
  }