uniweb 0.12.26 → 0.12.27

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.
@@ -13,73 +13,49 @@
13
13
  * and hand it to a host adapter for upload + invalidation. No login,
14
14
  * no edge.
15
15
  *
16
- * For static-host artifacts WITHOUT upload, see `uniweb export`.
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`.
17
18
  *
18
- * Default-flow steps:
19
- * 1. Read site.yml { site.id?, site.handle?, foundation, runtime? }.
20
- * 2. Resolve runtime (default: GET /runtime/latest from the Worker).
21
- * 3. ensureAuth() bearer CLI JWT from ~/.uniweb/auth.json.
22
- * 4. Build `dist/` if missing.
23
- * 5. Load dist/site-content.json extract `languages` for the capability
24
- * preview.
25
- * 6. Start an ephemeral loopback listener for the browser-callback path.
26
- * 7. POST PHP /cli-deploy.php?action=authorize with { siteId?, foundation,
27
- * runtimeVersion, languages, callbackUrl }.
28
- * 8. Branch:
29
- * - publishToken returned fast path.
30
- * - needsReview:true + reviewUrl open browser, wait for callback,
31
- * consume { publishToken, siteId, handle }.
32
- * 9. POST Worker /publish/check to confirm foundation + runtime
33
- * exist and the token's namespace claim matches.
34
- * 10. POST Worker /publish with the full payload.
35
- * 11. On first-deploy create flow: write site.id + site.handle back into
36
- * site.yml so subsequent deploys fast-path.
19
+ * Uniweb-host flow (deployToUniwebBackend):
20
+ * 1. Resolve the site dir + the deploy.yml target.
21
+ * 2. discover() the backend's anonymous capability handshake (GET /dev/config):
22
+ * delivery support + the installed-runtime list.
23
+ * 3. Resolve the runtime: `site.yml::runtime` if pinned, else the highest
24
+ * version the backend reports installed (fail closed if neither resolves).
25
+ * 4. Build the site data (link mode): site-content.json (+ per-locale variants),
26
+ * collection data, search indexes.
27
+ * 5. Assemble the deploy payload (foundation, runtimeVersion, theme, languages,
28
+ * defaultLanguage, locales, optional dataFiles/searchFiles).
29
+ * 6. POST /dev/deploy via BackendClient — the login bearer authorizes; on first
30
+ * deploy the backend mints a delivery uuid (round-tripped through
31
+ * deploy.yml::lastDeploy) and returns the serve URL.
37
32
  *
38
33
  * Usage:
39
- * uniweb deploy Normal deploy (browser may open on first deploy)
40
- * uniweb deploy --dry-run Resolve everything but skip the Worker POST
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
34
+ * uniweb deploy Build + deploy to the resolved target
35
+ * uniweb deploy --dry-run Resolve everything; POST nothing
36
+ * uniweb deploy --target <name> Pick a target from deploy.yml (default: its `default:`)
37
+ * uniweb deploy --host <name> One-off host override (not persisted to deploy.yml)
38
+ * uniweb deploy --no-save Skip the deploy.yml lastDeploy auto-save
39
+ * uniweb deploy --backend <url> Override the backend origin
46
40
  *
47
- * Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
48
- * UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
49
- * UNIWEB_SKIP_ASSETS=1 Skip the asset upload step
50
- * UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
51
- * UNIWEB_FORCE_REVIEW=1 Force the browser review flow
52
- * UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
41
+ * Backend: BackendClient. Origin from --backend/--registry > UNIWEB_REGISTER_URL
42
+ * > the default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
43
+ *
44
+ * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/ (static-host flow).
53
45
  */
54
46
 
55
- import { createServer } from 'node:http'
56
47
  import { existsSync } from 'node:fs'
57
- import { readFile, writeFile, readdir, stat } from 'node:fs/promises'
58
- import { resolve, join, basename, relative, sep } from 'node:path'
48
+ import { readFile, readdir } from 'node:fs/promises'
49
+ import { resolve, join, relative } from 'node:path'
59
50
  import { execSync } from 'node:child_process'
60
51
  import yaml from 'js-yaml'
61
52
 
62
- import { detectFoundationType } from '@uniweb/build'
63
- import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
53
+ import { loadDeployYml, resolveTarget, recordLastDeploy, rewriteSiteContentPaths } from '@uniweb/build/site'
64
54
  import { promptForHost } from '../utils/host-prompt.js'
65
55
  import { readFlagValue } from '../utils/args.js'
66
-
67
- import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
68
- import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
69
56
  import { parseBoolEnv } from '../utils/env.js'
70
- import { RemoteRegistry } from '../utils/registry.js'
71
-
72
- /**
73
- * Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
74
- * Returns null on any shape we don't recognize. Inlined here after the
75
- * receipt-cache utility module was removed in Phase 4b — the only
76
- * remaining caller is the staleness check below.
77
- */
78
- function splitRegistryRef(ref) {
79
- if (typeof ref !== 'string') return null
80
- const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
81
- return m ? { name: m[1], version: m[2] } : null
82
- }
57
+ import { BackendClient } from '../backend/client.js'
58
+ import { collectSiteAssets } from '../utils/asset-upload.js'
83
59
 
84
60
  import {
85
61
  findWorkspaceRoot,
@@ -89,10 +65,6 @@ import {
89
65
  } from '../utils/workspace.js'
90
66
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
91
67
 
92
- const REVIEW_TIMEOUT_MS = 15 * 60 * 1000 // 15 min — matches PHP session TTL
93
- const ASSET_UPLOAD_CONCURRENCY = 6
94
- const ASSET_UPLOAD_RETRIES = 2
95
-
96
68
  const FOUNDATION_POLICIES = new Set(['exact', 'auto-patch', 'auto-minor'])
97
69
 
98
70
  /**
@@ -156,32 +128,6 @@ function parseSiteFoundation(input) {
156
128
  }
157
129
  }
158
130
 
159
- // Vite content-addresses these formats. Same filename → same content, so we
160
- // can skip upload without checking size. Unhashed formats fall through to
161
- // size-compare diffing.
162
- const VITE_HASHED_FILENAME_RE = /-[0-9a-f]{8,}\.[a-z0-9]+$/i
163
-
164
- // MEDIA extensions only — images, fonts, documents, video/audio. dist/assets/
165
- // also contains Vite's JS/CSS chunks and source maps, which are code, not
166
- // user media, and are served by the Worker from elsewhere (runtime bundle +
167
- // content injection). Uploading those is wasted storage — they're never
168
- // referenced. Mirror of ProfileAsset's ALLOWED_EXTENSIONS minus the text
169
- // formats that have no place in a static media bucket.
170
- const MEDIA_EXTENSIONS = new Set([
171
- 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico',
172
- 'pdf', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'xlsm', 'xlsb',
173
- 'mp4', 'webm', 'ogg',
174
- 'woff', 'woff2', 'ttf', 'otf', 'eot',
175
- ])
176
- const MIME_BY_EXT = {
177
- webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
178
- gif: 'image/gif', svg: 'image/svg+xml', ico: 'image/x-icon',
179
- pdf: 'application/pdf',
180
- woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
181
- eot: 'application/vnd.ms-fontobject',
182
- mp4: 'video/mp4', webm: 'video/webm', ogg: 'audio/ogg',
183
- }
184
-
185
131
  const c = {
186
132
  reset: '\x1b[0m',
187
133
  bold: '\x1b[1m',
@@ -199,241 +145,11 @@ const say = {
199
145
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
200
146
  }
201
147
 
202
- /**
203
- * Read the git state for `dir`, scoped to that directory's history and
204
- * working tree — NOT the whole repo's HEAD.
205
- *
206
- * `gitSha` : last commit that touched `dir` (`git log -1 -- .`).
207
- * `gitDirty`: uncommitted changes inside `dir` only (`git status -- .`).
208
- *
209
- * Why scope it. In a multi-package monorepo, `git rev-parse HEAD` is
210
- * the same value for every directory — the repo's current HEAD. That
211
- * meant editing a SITE then deploying triggered the foundation's
212
- * staleness check (its receipt's recorded sha didn't match the new
213
- * repo HEAD), even though the foundation source was unchanged. The
214
- * receipt's `publishedFromGitSha` field is per-foundation by design;
215
- * the comparison side has to be too.
216
- *
217
- * If the path is outside a git repo, or has no commits touching it
218
- * yet, the function returns `{ gitSha: null, gitDirty: false }` —
219
- * same fallback shape as before.
220
- */
221
- function readGitState(dir) {
222
- try {
223
- // `git log -1 --format=%H -- .` returns the SHA of the last
224
- // commit that touched the cwd path. If no such commit exists
225
- // yet (path was never committed), output is empty — caller
226
- // treats null as "no published-from-sha to compare against."
227
- const sha = execSync('git log -1 --format=%H -- .', {
228
- cwd: dir,
229
- stdio: ['ignore', 'pipe', 'ignore'],
230
- }).toString().trim()
231
- const status = execSync('git status --porcelain -- .', {
232
- cwd: dir,
233
- stdio: ['ignore', 'pipe', 'ignore'],
234
- }).toString()
235
- return { gitSha: sha || null, gitDirty: status.length > 0 }
236
- } catch {
237
- return { gitSha: null, gitDirty: false }
238
- }
239
- }
240
-
241
- function composeFoundationUrl(ref, registryBase) {
242
- if (typeof ref !== 'string') return null
243
- if (ref.startsWith('https://') || ref.startsWith('http://')) return ref
244
- const m = ref.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/)
245
- if (!m || !registryBase) return null
246
- const [, name, version] = m
247
- return `${registryBase.replace(/\/$/, '')}/${name}/${version}/`
248
- }
249
-
250
- /**
251
- * Decide whether a workspace-local foundation is stale relative to the
252
- * registry's record, by comparing per-directory git provenance against
253
- * the registry entry's `publishedFromGitSha`. No local cache file —
254
- * `dist/publish.json` was deleted in Phase 4b of the CLI ergonomics
255
- * overhaul because every fresh clone / CI run / collaborator paid the
256
- * registry round-trip anyway, and the local cache only added confusing
257
- * "stale receipt" warnings when collaborators had different `dist/`
258
- * state.
259
- *
260
- * Returns `{ stale, reason }`. The caller decides whether to auto-publish
261
- * (Phase 2 default) or fail (`--no-auto-publish`).
262
- */
263
- async function inspectFoundationStaleness(localPath, { dirtyAsStale, registry, ref }) {
264
- const { gitSha, gitDirty } = readGitState(localPath)
265
- if (!gitSha) {
266
- return { stale: true, reason: 'foundation directory is not in a git repo or has no commits' }
267
- }
268
-
269
- const split = splitRegistryRef(ref)
270
- if (!split) {
271
- return { stale: true, reason: 'cannot derive registry ref from package.json' }
272
- }
273
-
274
- let existingEntry
275
- try {
276
- existingEntry = await registry.getVersionEntry(split.name, split.version)
277
- } catch {
278
- return { stale: true, reason: 'registry lookup failed' }
279
- }
280
- if (!existingEntry) {
281
- return { stale: true, reason: `${split.name}@${split.version} not yet published` }
282
- }
283
-
284
- if (existingEntry.publishedFromGitSha && existingEntry.publishedFromGitSha !== gitSha) {
285
- // Recorded sha differs from the foundation's per-directory
286
- // last-touched commit. Normally that's "real" staleness — somebody
287
- // committed changes to src/ that haven't been republished.
288
- //
289
- // Exception: when the publish was made FROM A DIRTY tree, the
290
- // recorded sha is a checkpoint, not an identity. The published
291
- // artifact reflects the committed state at that sha PLUS the
292
- // uncommitted changes that were on disk when publish ran. After
293
- // those changes get committed (a normal post-deploy housekeeping
294
- // step — e.g., committing the auto-derived `uniweb.id`), the
295
- // per-foundation sha moves forward, but the artifact upstream
296
- // hasn't materially changed. Don't fire staleness on the sha
297
- // alone in that case; let the dirty-tree check below do its job
298
- // if the tree IS still dirty, and otherwise treat as fresh.
299
- if (!existingEntry.publishedFromGitDirty) {
300
- return {
301
- stale: true,
302
- reason: `foundation has new commits since last publish (${existingEntry.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
303
- }
304
- }
305
- }
306
- if (gitDirty && dirtyAsStale) {
307
- return { stale: true, reason: 'foundation working tree is dirty' }
308
- }
309
- return { stale: false }
310
- }
311
-
312
- /**
313
- * Last-resort canonical-name derivation for empty-scope foundations.
314
- * Combines `package.json::uniweb.id` (the foundation's bare name) with
315
- * the user's `memberUuid` claim from auth.json to produce
316
- * `~<memberUuid>/<id>@<version>`. Only fires when both inputs are
317
- * available — otherwise returns null and the caller falls through to
318
- * the republish path.
319
- */
320
- async function refFromAuthAndPkg(localPath) {
321
- let pkg
322
- try {
323
- pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
324
- } catch {
325
- return null
326
- }
327
- const id = pkg?.uniweb?.id
328
- const version = pkg?.version
329
- if (!id || !version || !/^[a-z0-9_-]+$/.test(id)) return null
330
- try {
331
- const auth = await readAuth()
332
- const claims = decodeJwtPayload(auth?.token)
333
- if (claims?.memberUuid) return `~${claims.memberUuid}/${id}@${version}`
334
- } catch { /* no auth — fall through to null */ }
335
- return null
336
- }
337
-
338
- /**
339
- * Read a workspace-local foundation's identity (scoped name + version) from
340
- * its `dist/meta/schema.json` + `package.json`, mirroring `publish.js`'s
341
- * namespace resolution. Returns the registry ref (`@ns/name@ver` or
342
- * `~uuid/name@ver`), or null if no shape can be resolved.
343
- *
344
- * Resolution order:
345
- * 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
346
- * 2. Empty-scope synthesis from `pkg.uniweb.id` + the user's auth claim
347
- * (`~<memberUuid>/<id>@<version>`). Same canonical shape the server
348
- * stores under for empty-scope publishes. Phase 4d will replace this
349
- * with `~{siteId}/...` derived from authorize.
350
- * 3. null — caller falls through to the helpful "set uniweb.namespace"
351
- * error message.
352
- */
353
- async function deriveLocalFoundationRef(localPath) {
354
- let pkg
355
- try {
356
- pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
357
- } catch {
358
- return null
359
- }
360
-
361
- let rawName, version
362
- try {
363
- const schema = JSON.parse(await readFile(join(localPath, 'dist', 'meta', 'schema.json'), 'utf8'))
364
- rawName = schema._self?.name
365
- version = schema._self?.version
366
- } catch {
367
- // Fallback to package.json when the build hasn't run yet.
368
- }
369
- rawName = rawName || pkg.name
370
- version = version || pkg.version
371
- if (!rawName || !version) return null
372
-
373
- // Org-scope path — derived purely from local files.
374
- const uniwebNamespace = pkg.uniweb?.namespace
375
- const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
376
- const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
377
- const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
378
- if (namespace) {
379
- const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
380
- return `@${namespace}/${bareName}@${version}`
381
- }
382
-
383
- // Empty-scope fallback: synthesize `~<memberUuid>/<id>@<version>` from
384
- // the user's auth + package.json::uniweb.id. Same canonical shape the
385
- // server stores under for empty-scope publishes. After Phase 4d this
386
- // path is replaced by `~{siteId}/...` derived from authorize.
387
- const fromAuth = await refFromAuthAndPkg(localPath)
388
- if (fromAuth) return fromAuth
389
-
390
- return null
391
- }
392
-
393
148
  // ─── Main ───────────────────────────────────────────────────
394
149
 
395
150
  export async function deploy(args = []) {
396
151
  const dryRun = args.includes('--dry-run')
397
- // When `foundation:` in site.yml points at a workspace-local file: ref,
398
- // deploy auto-publishes the foundation when the registry has no record
399
- // of the current source's git sha. This flag opts out.
400
- const autoPublishFoundation = !args.includes('--no-auto-publish')
401
-
402
- // --local: redirect platform URLs to the unicloud mock (localhost:4001)
403
- // for internal end-to-end testing. Documented in the workspace root
404
- // CLAUDE.md ("The --local Flag" section). NOT a public user-facing
405
- // feature — a real user has no unicloud server running. The flag is
406
- // intentionally absent from the global help to avoid leaking it into
407
- // user docs; per-command help (uniweb deploy --help) lists it under
408
- // an "Internal" caveat for the eval / test team.
409
- //
410
- // The override unconditionally pins both backend and worker to
411
- // http://localhost:4001 (unicloud's default port) regardless of any
412
- // env vars set in the calling shell. Auth is NOT skipped — the runbook
413
- // expects mock-login.js to seed ~/.uniweb/auth.json with a JWT
414
- // unicloud's verifyToken accepts.
415
- const isLocal = args.includes('--local')
416
-
417
- // Internal escape hatches — see framework/cli/docs/env-vars.md. These
418
- // are not user-facing flags; they exist for the platform test team,
419
- // CI scripts, and dev-loop unblockers. The bare `deploy` command should
420
- // do the right thing for normal users without any of them set.
421
- const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
422
- const skipAssets = parseBoolEnv('UNIWEB_SKIP_ASSETS')
423
- const skipBilling = parseBoolEnv('UNIWEB_SKIP_BILLING')
424
- const forceReview = parseBoolEnv('UNIWEB_FORCE_REVIEW')
425
- // Inverse of the (now-removed) --no-dirty-as-stale flag. When true, a
426
- // dirty workspace will NOT be treated as stale (won't trigger auto-publish
427
- // of the foundation). Default: dirty IS stale.
428
- const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
429
-
430
152
  const siteDir = await resolveSiteDir(args)
431
- const backendUrl = isLocal ? 'http://localhost:4001' : getBackendUrl()
432
- const workerUrl = isLocal ? 'http://localhost:4001' : getRegistryUrl()
433
- if (isLocal) {
434
- console.log(` \x1b[2m→ Local mock mode (unicloud at ${backendUrl}; see workspace root CLAUDE.md)\x1b[0m`)
435
- }
436
-
437
153
  // Read site.yml — declares the foundation (required) and optionally the
438
154
  // site.id / site.handle from prior deploys.
439
155
  const siteYmlPath = join(siteDir, 'site.yml')
@@ -518,522 +234,251 @@ export async function deploy(args = []) {
518
234
  say.dim('Foundation policy: exact (pinned)')
519
235
  }
520
236
 
521
- // --dry-run gate. Must come BEFORE auto-publish (which writes to the
522
- // registry) and BEFORE the site build (which writes to dist/). Earlier
523
- // versions of this command had the dry-run check after both, which
524
- // violated the contract that --dry-run performs zero writes. Languages
525
- // and the default locale are unavailable here (they live in
526
- // dist/site-content.json, which a dry-run won't build); the trade-off
527
- // is intentional. Run `uniweb build` directly if you need that detail.
528
- if (dryRun) {
529
- say.info('Dry run — would deploy:')
530
- say.dim(`Site dir : ${siteDir}`)
531
- say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
532
- say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
533
- say.dim(`Runtime : ${siteYml.runtime || '(latest, resolved at authorize)'}`)
534
- say.dim(`Backend (PHP) : ${backendUrl}`)
535
- say.dim(`Worker : ${workerUrl}`)
536
- return
537
- }
538
-
539
- // `uniweb deploy` always runtime-links: the edge serves a runtime
540
- // template + per-site base.html, with the foundation loaded by URL.
541
- // The historical --link / --bundle flags are gone (Phase 2 of the CLI
542
- // ergonomics overhaul). For static-host artifacts, see `uniweb export`.
543
-
544
- // Phase 2: resolve workspace-local `file:` foundation refs.
545
- //
546
- // The object form of `foundation:` already requires a registry ref
547
- // (`@ns/name@ver`) per parseSiteFoundation, so only the string form can
548
- // resolve to a local path. Pass-through cases (registry ref, full URL,
549
- // npm package) all leave `foundation` untouched. The resolved registry
550
- // ref is also passed to the site build via UNIWEB_FOUNDATION_REF so the
551
- // build runs in runtime mode against the just-published artifact instead
552
- // of bundling the local foundation source. site.yml on disk is never
553
- // modified.
554
- // Phase 4d: detect a workspace-local foundation. The actual upload happens
555
- // AFTER authorize (which mints siteId), so the canonical site-bound ref
556
- // `~{siteId}/{name}@{ver}` is known by the time we publish. For now we
557
- // just record what we'll need at upload time and pass a `~self/...`
558
- // placeholder to authorize — the server rewrites it.
559
- let localFoundation = null
560
- if (typeof foundation === 'string') {
561
- const detected = detectFoundationType(foundation, siteDir)
562
- if (detected.type === 'local') {
563
- const localPath = detected.path
564
- const relPath = relative(siteDir, localPath) || localPath
565
-
566
- let pkg
567
- try {
568
- pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
569
- } catch {
570
- say.err(`Could not read ${relPath}/package.json.`)
571
- process.exit(1)
572
- }
573
- const foundationName = pkg.uniweb?.id || pkg.name?.replace(/^[@~][^/]+\//, '') || pkg.name
574
- const foundationVersion = pkg.version
575
- if (!foundationName || !foundationVersion) {
576
- say.err(`Foundation at ${relPath} needs both a name and a version in package.json.`)
577
- process.exit(1)
578
- }
237
+ // Uniweb hosting the new backend's /dev/deploy delivery lane (BackendClient):
238
+ // one authed POST, no PHP authorize, no Worker publish, no JWT. Backend chosen by
239
+ // origin only; capabilities + installed runtimes discovered via GET /dev/config.
240
+ // Foundation/runtime resolution, payload assembly, the POST, and the deploy.yml
241
+ // uuid round-trip all live in deployToUniwebBackend. The legacy PHP-authorize +
242
+ // Worker-publish flow below is retired by this routing (excised on cutover).
243
+ await deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave })
244
+ return
245
+ }
579
246
 
580
- localFoundation = {
581
- path: localPath,
582
- relPath,
583
- name: foundationName,
584
- version: foundationVersion,
585
- }
247
+ // ─── Uniweb-backend deploy (the /dev/deploy delivery lane) ────────────────
248
+ //
249
+ // Hosts a file-built site on the Uniweb backend through BackendClient: one authed
250
+ // POST /dev/deploy carrying the deploy payload `build-site-data.js` produces. The
251
+ // login bearer authorizes (the account IS the authorization) — no PHP authorize,
252
+ // no Worker publish, no JWT, no asset-presign dance. Backend is chosen by origin
253
+ // only (--backend/--registry > UNIWEB_REGISTER_URL > default); everything else is
254
+ // discovered via GET /dev/config (capabilities + installed runtimes). Replaces the
255
+ // legacy PHP+Worker flow in deploy() above.
256
+
257
+ async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave }) {
258
+ const client = new BackendClient({
259
+ originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
260
+ token: readFlagValue(args, '--token'),
261
+ args,
262
+ command: 'Deploying',
263
+ })
586
264
 
587
- // Send `~self/{name}@{ver}` as a placeholder. The server will rewrite
588
- // to `~{siteId}/{name}@{ver}` once siteId is minted. The CLI uses the
589
- // returned canonical ref for both the upload and the publish payload.
590
- foundation = `~self/${foundationName}@${foundationVersion}`
591
- }
592
- }
593
- // Honor --no-auto-publish for local foundations: surface the gate before
594
- // we do any work.
595
- if (localFoundation && !autoPublishFoundation) {
596
- say.err(`Local foundation at ${localFoundation.relPath} would be auto-published as part of deploy.`)
597
- say.dim('Drop --no-auto-publish to let deploy publish it, or change site.yml to reference a registry-published foundation.')
265
+ // Steer-hint (not enforced): a site that's been synced (has site.yml::$uuid) is
266
+ // CMS-managed `uniweb release` publishes its current backend state, including
267
+ // app-side edits. `deploy` still works: it hosts the LOCAL file-built payload as
268
+ // a separate delivery (deploy ⊥ publish — alternative lifecycles, your choice).
269
+ if (siteYml.$uuid) {
270
+ say.warn('This site is synced (site.yml::$uuid) — CMS-managed.')
271
+ say.dim('`uniweb release` publishes its current backend state; `deploy` hosts your local files as a separate delivery.')
272
+ }
273
+
274
+ // Anonymous capability handshake (cached). Confirms the deploy lane is offered
275
+ // and supplies the installed-runtime list (the /dev/config replacement for the
276
+ // retired Worker /runtime/latest).
277
+ const config = await client.discover()
278
+ if (config?.delivery && config.delivery.deploy === false) {
279
+ say.err(`Backend at ${client.origin} does not offer the deploy lane (delivery.deploy=false).`)
598
280
  process.exit(1)
599
281
  }
600
282
 
601
- // Runtime defaults to "latest" resolved at authorize time.
602
- let runtimeVersion = siteYml.runtime
283
+ // Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
284
+ // version the backend reports installed; else fail closed with a clear
285
+ // precondition error (better than serving a site with no runtime — §9.4).
286
+ const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
287
+ if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
288
+ say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
289
+ say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
290
+ process.exit(1)
291
+ }
292
+ const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
603
293
  if (!runtimeVersion) {
604
- runtimeVersion = await fetchLatestRuntime(workerUrl)
605
- if (!runtimeVersion) {
606
- say.err('Could not resolve a runtime version (no runtime: in site.yml, /runtime/latest failed).')
607
- process.exit(1)
608
- }
609
- say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
294
+ say.err('Could not resolve a runtime version.')
295
+ say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
296
+ process.exit(1)
610
297
  }
611
298
 
612
- // Optional `features:` declaration. Acts as a "request" CLI sends to
613
- // PHP, PHP routes through review when it differs from the site's current
614
- // metadata. Unknown names get warned + dropped before sending so a typo
615
- // doesn't fail the whole deploy.
616
- const desiredFeatures = readFeaturesFromYaml(siteYml)
299
+ // deploy.yml uuid round-trip: a prior deploy recorded the minted delivery uuid
300
+ // under lastDeploy.<target>.siteUuid; resend it so /gateway/site/{uuid}/ stays
301
+ // stable. First deploy has none the backend mints one and we write it back.
302
+ const priorUuid = readDeployedSiteUuid(deployYml, resolved.targetName)
617
303
 
618
- const cliToken = await ensureAuth({ command: 'Deploying', args })
304
+ if (dryRun) {
305
+ say.info('Dry run — would deploy to the Uniweb backend:')
306
+ say.dim(`Backend : ${client.origin}`)
307
+ say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
308
+ say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
309
+ say.dim(`site_uuid : ${priorUuid || '(none — backend will mint)'}`)
310
+ return
311
+ }
312
+
313
+ // Build (link mode): emits dist/site-content.json (+ per-locale variants under
314
+ // dist/<lang>/), dist/data/*, dist/_search/*. Spawn the SAME CLI binary that's
315
+ // running so the inner build can't resolve to a different installed version.
316
+ say.info('Building site…')
317
+ console.log('')
318
+ execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
319
+ cwd: siteDir,
320
+ stdio: 'inherit',
321
+ env: process.env,
322
+ })
323
+ console.log('')
619
324
 
620
- // Always rebuild unless the user explicitly opts out with --skip-build.
621
- // A stale dist/ from a previous build + edited content on disk would
622
- // otherwise silently ship yesterday's version — a footgun big enough
623
- // to warrant the extra seconds every deploy.
624
325
  const distDir = join(siteDir, 'dist')
625
326
  const contentPath = join(distDir, 'site-content.json')
626
- if (!skipBuild) {
627
- say.info('Building site…')
628
- console.log('')
629
- // No VITE_FOUNDATION_MODE override needed: @uniweb/build's
630
- // detectFoundationType recognizes `@ns/name@version` refs as
631
- // link-mode URLs, which auto-enters runtime mode. Prerender also
632
- // auto-skips for link-mode foundations (HTML is rendered on the
633
- // serving edge, not here). Always --link: the edge serves a runtime
634
- // template + per-site base.html, never a self-contained vite bundle.
635
- //
636
- // Phase 4d: workspace-local foundations carry the `~self/{name}@{ver}`
637
- // placeholder at this point; the canonical `~{siteId}/...` ref isn't
638
- // known until authorize returns. Link mode doesn't run vite or fetch
639
- // the foundation, so site-content.json's foundation field reflects
640
- // whatever's in site.yml — that's fine because the publish payload
641
- // overrides it with the canonical form post-authorize.
642
- //
643
- // Spawn the SAME CLI binary that's currently running rather than
644
- // `npx uniweb build` — npx walks node_modules and would resolve to
645
- // whatever version is installed there (which might be older than
646
- // the deploy CLI and silently ignore --link). `process.argv[1]`
647
- // pins the inner build to the outer's exact version.
648
- execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
649
- cwd: siteDir,
650
- stdio: 'inherit',
651
- env: process.env,
652
- })
653
- console.log('')
654
- } else if (!existsSync(contentPath)) {
655
- say.err('No build found and UNIWEB_SKIP_BUILD set. Run `uniweb build` first.')
656
- process.exit(1)
657
- }
658
327
  if (!existsSync(contentPath)) {
659
328
  say.err('Build did not produce dist/site-content.json')
660
329
  process.exit(1)
661
330
  }
662
331
 
663
- // Read site-content.json — we need `languages` for the capability preview
664
- // and the whole object for the publish payload.
665
332
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
666
333
  const languages = extractLanguages(siteContent)
667
- const languageLabels = extractLanguageLabels(siteContent)
668
334
  const defaultLanguage = siteContent?.config?.defaultLanguage || languages[0] || 'en'
669
335
  const theme = await readTheme(siteDir, siteContent)
670
336
 
671
- // Multi-locale: @uniweb/build emits dist/<lang>/site-content.json per
672
- // non-default locale via buildLocalizedContent (translations applied via
673
- // locales/<lang>.json + freeform/). Load each one so we can ship a full
674
- // locales: map in the publish payload — same shape as Editor publish.
675
- // Single-locale sites just have the default and skip the loop.
337
+ // Per-locale content: default + each non-default dist/<lang>/site-content.json.
676
338
  const localeContents = { [defaultLanguage]: siteContent }
677
339
  for (const lang of languages) {
678
340
  if (lang === defaultLanguage) continue
679
- const localeContentPath = join(distDir, lang, 'site-content.json')
680
- if (existsSync(localeContentPath)) {
681
- localeContents[lang] = JSON.parse(await readFile(localeContentPath, 'utf8'))
682
- } else {
683
- say.warn(`Locale "${lang}" listed in site config but no dist/${lang}/site-content.json found — skipping.`)
684
- }
341
+ const p = join(distDir, lang, 'site-content.json')
342
+ if (existsSync(p)) localeContents[lang] = JSON.parse(await readFile(p, 'utf8'))
343
+ else say.warn(`Locale "${lang}" listed in site config but no dist/${lang}/site-content.json skipping.`)
685
344
  }
686
345
 
687
- // Spin up the loopback listener eagerly we need its callback URL for the
688
- // authorize request even on the fast path (PHP may always return
689
- // needsReview=true on first deploy / billing drift in future phases).
690
- const loopback = await startLoopback()
691
-
692
- let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl, mintedFeatures
693
- let foundationUploadUrl // Phase 4d: returned by authorize for site-bound foundation uploads
694
- try {
695
- say.info('Requesting deploy authorization…')
696
- const authorizeBody = {
697
- siteId: siteYml.site?.id || '',
698
- foundation,
699
- runtimeVersion,
700
- languages,
701
- // Optional `{ code: label }` map from site.yml's object-form
702
- // languages. PHP stamps this into the session JWT so CliDeployReview
703
- // can use real labels (English, Français, …) when provisioning the
704
- // site, instead of falling back to `lang.toUpperCase()`.
705
- ...(languageLabels ? { languageLabels } : {}),
706
- // `name` from site.yml is a hint for the create-flow review page so
707
- // the handle input is pre-filled. Ignored by authorize in other
708
- // branches (fast path, intent=authorize).
709
- name: typeof siteYml.name === 'string' ? siteYml.name : '',
710
- callbackUrl: loopback.callbackUrl,
711
- // Dev-only: admin-gated server-side. PHP rejects for non-admins.
712
- skipBilling: skipBilling || undefined,
713
- // site.yml-declared target feature set. PHP routes through review
714
- // (with the desired set pre-applied) when it differs from DB.
715
- // Always sent as an array; missing/empty `features:` in site.yml
716
- // is normalized to `[]`, meaning "no paid features".
717
- desiredFeatures,
718
- // User-forced review (UNIWEB_FORCE_REVIEW=1). PHP refuses to
719
- // fast-path even when nothing else has drifted.
720
- forceReview: forceReview || undefined,
721
- }
722
- let authRes
723
- try {
724
- authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
725
- } catch (err) {
726
- // Stale-siteId recovery: the user's site.yml points at a site that
727
- // no longer exists on the server (deleted, different env, etc.).
728
- // Warn, drop the siteId, and retry — we'll land in the create flow
729
- // and write a fresh site.id back to site.yml after success.
730
- if (err.status === 404 && authorizeBody.siteId) {
731
- say.warn(`site.id "${authorizeBody.siteId}" was not found on the server.`)
732
- say.dim('Treating as a new site — the create flow will run in your browser.')
733
- authorizeBody.siteId = ''
734
- authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
735
- } else if (err.status === 403 && authorizeBody.siteId) {
736
- // Collaborator ACL — the user has the repo (and thus site.id in
737
- // site.yml) but isn't owner or editor on this site. The server's
738
- // 403 message names the owner; surface it verbatim.
739
- say.err(err.message)
740
- process.exit(1)
741
- } else {
742
- say.err(`Authorize failed: ${err.message}`)
346
+ // Collection JSON (→ /data/<key>) and search indexes (→ /_search/<key>) two
347
+ // distinct serve namespaces on the backend.
348
+ const dataFiles = await collectDataFiles(distDir)
349
+ if (Object.keys(dataFiles).length) say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
350
+ const searchFiles = await collectSearchFiles(distDir)
351
+ if (Object.keys(searchFiles).length) say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
352
+
353
+ // Assets: when the backend advertises the lane (config.assets.supported), upload
354
+ // the site's processed media (dist/assets/*) to the content-addressed store and
355
+ // rewrite the content's local refs to durable serve URLs. Image-free sites
356
+ // collect nothing and deploy unchanged; an un-advertised lane is skipped.
357
+ // Contract: kb/framework/build/delivery-lane.md §Assets (channel f90d).
358
+ if (config?.assets && config.assets.supported === false) {
359
+ say.dim('Asset lane not yet available on this backend — skipping upload (image-free deploy).')
360
+ } else {
361
+ const assetFiles = collectSiteAssets(distDir)
362
+ if (assetFiles.length) {
363
+ say.info(`Uploading ${assetFiles.length} asset(s)…`)
364
+ let assetResult
365
+ try {
366
+ assetResult = await client.uploadSiteAssets({ distDir, files: assetFiles, onProgress: (m) => say.dim(` ${m}`) })
367
+ } catch (err) {
368
+ say.err(`Asset upload failed: ${err.message}`)
743
369
  process.exit(1)
744
370
  }
745
- }
746
-
747
- if (authRes.needsReview) {
748
- const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
749
- // openBrowser returns a hint about whether a GUI was available. On
750
- // headless/CI environments (no DISPLAY, SSH session, no browser
751
- // command), we print the URL + clear instructions instead of just
752
- // "timed out" 15 minutes later.
753
- say.info(`Opening browser for ${flowLabel}…`)
754
- say.dim(authRes.reviewUrl)
755
- const opened = await openBrowser(authRes.reviewUrl)
756
- console.log('')
757
- if (opened === false) {
758
- say.warn('No browser could be launched in this environment.')
759
- console.log(`${c.dim}Open this URL manually to complete the ${flowLabel}:${c.reset}`)
760
- console.log(` ${authRes.reviewUrl}`)
761
- console.log('')
762
- console.log(`${c.dim}The browser must be able to POST to this CLI's loopback listener:${c.reset}`)
763
- console.log(` ${loopback.callbackUrl}`)
764
- console.log(`${c.dim}If you're in CI or over SSH, run this deploy from a machine with a browser.${c.reset}`)
765
- console.log('')
766
- }
767
- console.log(`${c.dim}Awaiting authorization…${c.reset}`)
768
- console.log(`${c.dim}(Will time out after ${REVIEW_TIMEOUT_MS / 60000} minutes)${c.reset}`)
769
- console.log('')
770
-
771
- const cb = await loopback.waitForCallback(REVIEW_TIMEOUT_MS)
772
- if (!cb || !cb.publishToken) {
773
- say.err('Browser authorization timed out or was denied.')
774
- if (opened === false) {
775
- say.dim('Hint: the browser may have run on a different machine and couldn\'t reach this CLI\'s loopback.')
776
- }
371
+ if (assetResult.failed.length) {
372
+ say.err(`${assetResult.failed.length} asset(s) failed to upload:`)
373
+ for (const f of assetResult.failed) say.dim(` ${f.path} — HTTP ${f.status} ${f.detail}`)
777
374
  process.exit(1)
778
375
  }
779
- publishToken = cb.publishToken
780
- siteIdResolved = cb.siteId
781
- handleResolved = cb.handle
782
- // PHP echoes the live feature set in the loopback callback so the
783
- // CLI can write `features:` back into site.yml accurately. Older
784
- // PHP that doesn't include this field is a no-op.
785
- mintedFeatures = Array.isArray(cb.features) ? cb.features : null
786
- // Phase 4d: workspace-local foundation deploys on the create flow
787
- // need the rewritten `~{siteId}/{name}@{ver}` ref + upload endpoint.
788
- // PHP/unicloud put them in the finalize response; the web app
789
- // forwards them to the loopback. Catalog-ref deploys leave them
790
- // undefined and we fall back to the placeholder/derived URL below.
791
- if (cb.foundationRef) foundation = cb.foundationRef
792
- if (cb.foundationUploadUrl) foundationUploadUrl = cb.foundationUploadUrl
793
- // Review path: Worker URLs are implicit (we derive them from config).
794
- publishUrl = `${workerUrl}/publish`
795
- validateUrl = `${workerUrl}/publish/check`
796
- } else {
797
- publishToken = authRes.publishToken
798
- siteIdResolved = authRes.siteId
799
- handleResolved = authRes.handle
800
- publishUrl = authRes.publishUrl
801
- validateUrl = authRes.validateUrl
802
- foundationUploadUrl = authRes.foundationUploadUrl
803
- mintedFeatures = Array.isArray(authRes.features) ? authRes.features : null
804
- // Phase 4d: server returns the canonical foundation ref. For
805
- // `~self/...` placeholders this is the rewritten `~{siteId}/...`
806
- // form; catalog refs pass through. The CLI uses this for both the
807
- // foundation upload (next step) and the publish payload below.
808
- if (authRes.foundationRef) foundation = authRes.foundationRef
809
- }
810
- } finally {
811
- loopback.close()
812
- }
813
-
814
- // Write site.id / handle to site.yml AS SOON as we have them, before any
815
- // step that can fail (validate, asset upload, publish). On first deploy
816
- // the user has already paid by this point — losing the link to the
817
- // server's site row would force a duplicate-create on the next attempt
818
- // (and a second subscription). The features write happens later after
819
- // publish; this early write only covers id/handle.
820
- if (siteIdResolved && !siteYml.site?.id) {
821
- await writeSiteYmlUpdates(siteYmlPath, siteYml, {
822
- site: { id: siteIdResolved, handle: handleResolved },
823
- })
824
- siteYml.site = { ...(siteYml.site || {}), id: siteIdResolved, handle: handleResolved }
825
- say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
826
- }
827
-
828
- // Phase 4d: upload site-bound foundation files directly. Replaces the
829
- // pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
830
- // canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
831
- // /foundations endpoint accepts the publish token's siteId claim
832
- // for this scope.
833
- if (localFoundation) {
834
- say.info(`Building foundation at ${localFoundation.relPath}…`)
835
- console.log('')
836
- try {
837
- execSync(`node ${JSON.stringify(process.argv[1])} build`, {
838
- cwd: localFoundation.path,
839
- stdio: 'inherit',
840
- })
841
- } catch {
842
- say.err(`Foundation build at ${localFoundation.relPath} failed. See output above.`)
843
- process.exit(1)
844
- }
845
- console.log('')
846
-
847
- say.info(`Uploading foundation as ${foundation}…`)
848
- const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
849
- const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
850
- const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
851
- await callFoundationUpload({
852
- url: foundationPublishUrl,
853
- token: publishToken,
854
- body: {
855
- name: foundation.replace(/@[^@]+$/, ''), // strip `@version` to get `~{siteId}/{name}`
856
- version: localFoundation.version,
857
- files: foundationFiles,
858
- metadata: {
859
- ...(fGitSha ? { publishedFromGitSha: fGitSha } : {}),
860
- ...(typeof fGitDirty === 'boolean' ? { publishedFromGitDirty: fGitDirty } : {}),
861
- },
862
- },
863
- })
864
- say.ok(`Foundation uploaded.`)
865
- }
866
-
867
- // Pre-flight against the Worker. Surfaces "foundation not published" /
868
- // "runtime not found" / namespace mismatch BEFORE we ship content.
869
- say.info('Validating foundation + runtime…')
870
- const validation = await callValidate({
871
- url: validateUrl,
872
- token: publishToken,
873
- body: { foundation, runtimeVersion },
874
- })
875
- if (!validation.valid) {
876
- say.err('Pre-flight validation failed:')
877
- for (const issue of validation.issues || []) {
878
- console.log(` ${c.red}${issue.code}${c.reset}: ${issue.message}`)
879
- if (issue.fix) console.log(` ${c.dim}${issue.fix}${c.reset}`)
880
- }
881
- process.exit(1)
882
- }
883
-
884
- // Collect compiled collection JSON files from dist/data/. The framework
885
- // emits these for `collection:` data sources — `<name>.json` cascade
886
- // payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
887
- // declared. Editor publish has no equivalent (collections live in the DB);
888
- // CLI sites need them shipped as static R2 objects.
889
- //
890
- // Read BEFORE the asset pipeline so the asset scan can pick up image
891
- // refs in collection JSON (e.g. `article.image: "/covers/foo.svg"`)
892
- // and the rewrite can swap them for CDN URLs alongside locale content.
893
- const dataFiles = await collectDataFiles(distDir)
894
- // Decode each data file as JSON so the asset scan can walk the tree;
895
- // mutated in place by the rewrite step. Re-stringified before publish.
896
- const dataFileObjects = {}
897
- for (const [k, raw] of Object.entries(dataFiles)) {
898
- try {
899
- dataFileObjects[k] = JSON.parse(raw)
900
- } catch {
901
- dataFileObjects[k] = null // unparseable — skip rewrite, ship as-is
902
- }
903
- }
904
- if (Object.keys(dataFiles).length > 0) {
905
- say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
906
- }
907
-
908
- const searchFiles = await collectSearchFiles(distDir)
909
- if (Object.keys(searchFiles).length > 0) {
910
- say.dim(`Search indexes : ${Object.keys(searchFiles).length} (_search/ JSON)`)
911
- }
912
-
913
- // Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
914
- // hits (public/, data file refs) to S3, then rewrite each locale's
915
- // siteContent + each parsed data file so the runtime resolves CDN URLs at
916
- // render time. Assets are locale-shared (they live in dist/assets/ +
917
- // public/ regardless of language); diff/upload runs once and the rewrite
918
- // walks every locale's content tree + every data-file JSON tree.
919
- // Skipped with --skip-assets.
920
- if (!skipAssets) {
921
- await uploadAssetsAndRewriteContent({
922
- siteDir,
923
- localeContents,
924
- dataFileObjects,
925
- siteYml,
926
- theme,
927
- backendUrl,
928
- cliToken,
929
- siteId: siteIdResolved,
930
- })
931
- // Re-stringify any data-file JSON that the rewrite step mutated, so the
932
- // publish payload below sees the rewritten URLs. Untouched files round-
933
- // trip identically.
934
- for (const k of Object.keys(dataFiles)) {
935
- if (dataFileObjects[k] !== null) {
936
- dataFiles[k] = JSON.stringify(dataFileObjects[k])
376
+ // Build localUrl → durable serve URL, then re-run the build's own rewrite
377
+ // over each locale's content (image nodes, marks, dataBlocks, param fields).
378
+ // assetBase comes from /dev/config (origin-relative in dev → prepend origin;
379
+ // absolute CDN in prod used verbatim).
380
+ const urlMapping = {}
381
+ for (const [localUrl, { id, ext }] of Object.entries(assetResult.assetsByLocalUrl)) {
382
+ urlMapping[localUrl] = buildAssetUrl(client.origin, config.assetBase, id, ext)
937
383
  }
384
+ for (const lang of Object.keys(localeContents)) {
385
+ localeContents[lang] = rewriteSiteContentPaths(localeContents[lang], urlMapping)
386
+ }
387
+ const skippedNote = assetResult.skipped?.length ? `, ${assetResult.skipped.length} already present` : ''
388
+ say.dim(`Assets : ${assetResult.uploaded.length} uploaded${skippedNote} (${assetResult.mode}) → ${config.assetBase}`)
938
389
  }
939
- } else {
940
- say.dim('Skipping asset upload (--skip-assets).')
941
390
  }
942
391
 
943
- say.info('Publishing…')
944
- const publishPayload = {
392
+ const payload = {
945
393
  foundation,
946
394
  runtimeVersion,
947
395
  theme,
948
396
  languages,
949
397
  defaultLanguage,
950
- // Compiled collection JSON files (relative-path → utf8 content). Worker
951
- // publish writes each to ${sitePrefix}/data/<key>; worker serve allows
952
- // /data/* paths from R2 alongside _pages/*.
953
- ...(Object.keys(dataFiles).length > 0 ? { dataFiles } : {}),
954
- ...(Object.keys(searchFiles).length > 0 ? { searchFiles } : {}),
955
- // Same shape as Editor publish — one entry per language. Single-locale
956
- // sites end up with `{ [defaultLanguage]: siteContent }`; multi-locale
957
- // sites carry per-locale translated content emitted by buildLocalizedContent.
398
+ ...(Object.keys(dataFiles).length ? { dataFiles } : {}),
399
+ ...(Object.keys(searchFiles).length ? { searchFiles } : {}),
400
+ // One entry per language single-locale sites end up with { [default]: content };
401
+ // multi-locale carry per-locale translated content. Same shape as Editor publish.
958
402
  locales: localeContents,
959
403
  }
960
- await callPublish({ url: publishUrl, token: publishToken, body: publishPayload })
961
404
 
962
- // Local event memory — used by future re-deploys (e.g., to skip
963
- // redundant work when nothing has changed). Lives under dist/ which is
964
- // gitignored; the platform never reads it.
965
- const foundationRef = typeof foundation === 'string' ? foundation : foundation?.ref
966
- const { gitSha, gitDirty } = readGitState(siteDir)
967
- const deployReceipt = {
968
- schemaVersion: 1,
969
- deployedFromGitSha: gitSha,
970
- deployedFromGitDirty: gitDirty,
971
- deployedAt: new Date().toISOString(),
972
- url: handleResolved ? `https://${handleResolved}.uniweb.website/` : null,
973
- foundation: {
974
- ref: foundationRef,
975
- url: composeFoundationUrl(foundationRef, getRegistryUrl()),
976
- },
977
- locales: languages,
405
+ say.info(`Deploying to ${c.dim}${client.origin}${c.reset} …`)
406
+ let res
407
+ try {
408
+ res = await client.deploy(payload, { siteUuid: priorUuid || undefined })
409
+ } catch (err) {
410
+ say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
411
+ say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
412
+ process.exit(1)
978
413
  }
979
- await writeFile(join(distDir, 'deploy.json'), JSON.stringify(deployReceipt, null, 2) + '\n')
980
-
981
- // Write site.id / site.handle / features back to site.yml so the file
982
- // stays in sync with the live billing state. site.id and site.handle
983
- // are written on first deploy and any time the server-side handle drifts.
984
- // `features:` is rewritten whenever the live (server-confirmed) set
985
- // differs from what's declared — including the case where the user
986
- // declared `[]` and the live set is `[]` (no diff, no write).
987
- const siteIdChanged = !!siteIdResolved && !siteYml.site?.id
988
- const handleChanged = !!siteIdResolved && !!handleResolved && siteYml.site?.handle !== handleResolved
989
- // desiredFeatures is what we sent to PHP (the simplified model: missing
990
- // == empty), so comparing mintedFeatures against it tells us whether
991
- // the file needs updating. Skip the write when nothing changed.
992
- const featuresChanged = mintedFeatures !== null
993
- && !arrayEqualsAsSets(desiredFeatures, mintedFeatures)
994
-
995
- if (siteIdChanged || handleChanged || featuresChanged) {
996
- const updates = {}
997
- if (siteIdChanged || handleChanged) {
998
- updates.site = { id: siteIdResolved, handle: handleResolved }
999
- }
1000
- if (featuresChanged) {
1001
- updates.features = mintedFeatures
1002
- }
1003
- await writeSiteYmlUpdates(siteYmlPath, siteYml, updates)
1004
- if (siteIdChanged) say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
1005
- else if (handleChanged) say.dim(`Updated site.yml handle → ${handleResolved}`)
1006
- if (featuresChanged) {
1007
- say.dim(`Updated site.yml features → [${mintedFeatures.join(', ') || '(none)'}]`)
414
+ if (!res.ok) {
415
+ say.err(`Deploy rejected: HTTP ${res.status} ${res.statusText}`)
416
+ if (res.status === 401 || res.status === 403) {
417
+ say.dim("Credentials weren't accepted run `uniweb login` (or pass --token <bearer>).")
1008
418
  }
419
+ const body = await res.text().catch(() => '')
420
+ if (body) say.dim(body.slice(0, 800))
421
+ process.exit(1)
1009
422
  }
423
+ let result
424
+ try { result = await res.json() } catch { result = {} }
1010
425
 
1011
- console.log('')
1012
- say.ok(`Deployed ${c.bold}${handleResolved || siteIdResolved || 'site'}${c.reset}`)
1013
- if (handleResolved) {
1014
- console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
1015
- }
426
+ const mintedUuid = result.site_uuid || priorUuid || null
427
+ const serveUrl = absolutizeServeUrl(client.origin, result.url)
1016
428
 
1017
- // Record a fresh lastDeploy.<target> entry. Skipped on --no-save (and
1018
- // on --host overrides, but uniweb-host can't be reached via override
1019
- // since the override branches into deployStaticHost above).
429
+ // Persist deploy memory + the minted uuid for the next round-trip. recordLastDeploy
430
+ // touches only lastDeploy.<target>, so siteUuid rides there safely.
1020
431
  await persistLastDeploy(siteDir, {
1021
432
  targetName: resolved.targetName,
1022
433
  targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
1023
434
  autoSave,
1024
435
  lastDeploy: {
1025
- at: deployReceipt.deployedAt,
436
+ at: new Date().toISOString(),
1026
437
  host: 'uniweb',
1027
- url: deployReceipt.url,
1028
- siteId: siteIdResolved,
1029
- handle: handleResolved,
1030
- foundation: {
1031
- shape: 'linked',
1032
- ref: foundationRef,
1033
- },
438
+ backend: client.origin,
439
+ siteUuid: mintedUuid,
440
+ url: serveUrl,
441
+ foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
1034
442
  runtime: runtimeVersion,
443
+ locales: Array.isArray(result.locales) ? result.locales : languages,
1035
444
  },
1036
445
  })
446
+
447
+ console.log('')
448
+ say.ok(`Deployed ${c.bold}${mintedUuid || 'site'}${c.reset}`)
449
+ if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
450
+ }
451
+
452
+ // Pick the highest runtime from the backend's installed list. localeCompare with
453
+ // numeric ordering puts '0.8.16' above '0.8.9' and orders the synthetic dev tags
454
+ // deterministically. Null when the list is empty.
455
+ function pickHighestRuntime(installed) {
456
+ if (!Array.isArray(installed) || installed.length === 0) return null
457
+ return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
458
+ }
459
+
460
+ // The previously-minted delivery uuid for `targetName` (lastDeploy.<target>.siteUuid
461
+ // in a loaded deploy.yml), or null on a first deploy / absent file.
462
+ function readDeployedSiteUuid(deployYml, targetName) {
463
+ return deployYml?.lastDeploy?.[targetName]?.siteUuid || null
464
+ }
465
+
466
+ // The deploy response `url` is the serve path. When origin-relative (the self-serve
467
+ // default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
468
+ // link is clickable; absolute URLs pass through unchanged.
469
+ function absolutizeServeUrl(origin, url) {
470
+ if (!url || typeof url !== 'string') return null
471
+ if (/^https?:\/\//.test(url)) return url
472
+ return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
473
+ }
474
+
475
+ // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
476
+ // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
477
+ // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
478
+ // `base`, {ext} the source extension the plan echoed.
479
+ function buildAssetUrl(origin, assetBase, id, ext) {
480
+ const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
481
+ return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
1037
482
  }
1038
483
 
1039
484
  // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
@@ -1182,67 +627,6 @@ async function readSiteYml(path) {
1182
627
  }
1183
628
  }
1184
629
 
1185
- // Recognized paid features. `features:` in site.yml uses these short
1186
- // names; the PHP backend maps them to internal metadata flags. Anything
1187
- // else gets dropped with a warning so a typo doesn't block a deploy.
1188
- const KNOWN_FEATURES = new Set(['search', 'analytics', 'lowTtl', 'intelligence'])
1189
-
1190
- function readFeaturesFromYaml(siteYml) {
1191
- // site.yml's `features:` is the developer's declarative intent for what
1192
- // paid features they want billed. We treat absence and `features: []` as
1193
- // the same thing — both mean "no paid features". This keeps the model
1194
- // simple: what's in the file is what the user wants. No "no opinion"
1195
- // escape hatch. Legacy sites that have paid features in DB but no
1196
- // features: line yet will see a downgrade-review on their next deploy
1197
- // (they cancel and add the explicit list, or proceed and downgrade).
1198
- const raw = siteYml?.features
1199
- if (raw === undefined) return []
1200
- if (!Array.isArray(raw)) {
1201
- say.warn('site.yml `features:` should be a list (e.g. `features: [search]`). Treating as empty.')
1202
- return []
1203
- }
1204
- const valid = []
1205
- const unknown = []
1206
- for (const v of raw) {
1207
- if (typeof v !== 'string') continue
1208
- if (KNOWN_FEATURES.has(v)) valid.push(v)
1209
- else unknown.push(v)
1210
- }
1211
- if (unknown.length > 0) {
1212
- say.warn(`site.yml features: unknown name(s) ignored: ${unknown.join(', ')}`)
1213
- say.dim(`Known features: ${[...KNOWN_FEATURES].join(', ')}`)
1214
- }
1215
- // Dedupe + stable order so authorize compares the same way every time.
1216
- return [...new Set(valid)].sort()
1217
- }
1218
-
1219
- function arrayEqualsAsSets(a, b) {
1220
- if (!Array.isArray(a) || !Array.isArray(b)) return false
1221
- if (a.length !== b.length) return false
1222
- const sa = new Set(a)
1223
- for (const x of b) if (!sa.has(x)) return false
1224
- return true
1225
- }
1226
-
1227
- /**
1228
- * Write a partial set of updates back to site.yml, preserving other fields.
1229
- *
1230
- * Note: this is not a full YAML-preserving write — comments and exact
1231
- * formatting are NOT preserved. js-yaml's `dump` re-emits the document.
1232
- * Acceptable for now; the Phase 1 plan doesn't promise comment preservation.
1233
- */
1234
- async function writeSiteYmlUpdates(path, current, updates) {
1235
- const next = { ...current }
1236
- if (updates.site) {
1237
- next.site = { ...(current.site || {}), ...updates.site }
1238
- }
1239
- if (updates.features !== undefined) {
1240
- next.features = [...updates.features].sort()
1241
- }
1242
- const dumped = yaml.dump(next, { lineWidth: 120, noRefs: true, quotingType: "'" })
1243
- await writeFile(path, dumped)
1244
- }
1245
-
1246
630
  // ─── Resolve site dir + runtime ────────────────────────────
1247
631
 
1248
632
  // Exported so `uniweb export` (commands/export.js) can reuse the same
@@ -1287,17 +671,6 @@ export async function resolveSiteDir(args, verb = 'deploy') {
1287
671
  process.exit(1)
1288
672
  }
1289
673
 
1290
- async function fetchLatestRuntime(workerUrl) {
1291
- try {
1292
- const res = await fetch(`${workerUrl}/runtime/latest`)
1293
- if (!res.ok) return null
1294
- const body = await res.json()
1295
- return body.version || null
1296
- } catch {
1297
- return null
1298
- }
1299
- }
1300
-
1301
674
  // ─── Content helpers ───────────────────────────────────────
1302
675
 
1303
676
  function extractLanguages(siteContent) {
@@ -1345,21 +718,6 @@ async function collectSearchFiles(distDir) {
1345
718
  return files
1346
719
  }
1347
720
 
1348
- // Optional per-language labels from site.yml's object form. Returns null when
1349
- // site.yml uses the plain-string form (no labels declared) — server falls back
1350
- // to its own defaults in that case.
1351
- function extractLanguageLabels(siteContent) {
1352
- const langs = siteContent?.config?.languages
1353
- if (!Array.isArray(langs)) return null
1354
- const labels = {}
1355
- for (const l of langs) {
1356
- if (typeof l === 'string') continue
1357
- const code = l?.value || l?.code
1358
- if (code && l?.label) labels[code] = l.label
1359
- }
1360
- return Object.keys(labels).length > 0 ? labels : null
1361
- }
1362
-
1363
721
  /**
1364
722
  * Resolve theme config.
1365
723
  *
@@ -1384,926 +742,3 @@ async function readTheme(siteDir, siteContent) {
1384
742
  }
1385
743
  return {}
1386
744
  }
1387
-
1388
- // ─── HTTP calls ────────────────────────────────────────────
1389
-
1390
- async function callAuthorize({ backendUrl, cliToken, body }) {
1391
- // PHP's BaseController reads the `action` from the JSON body (not the query
1392
- // string) when Content-Type: application/json. Every PHP POST needs to embed
1393
- // `action` in the payload.
1394
- const url = `${backendUrl}/cli-deploy.php`
1395
- const res = await fetch(url, {
1396
- method: 'POST',
1397
- headers: {
1398
- 'Content-Type': 'application/json',
1399
- Authorization: `Bearer ${cliToken}`,
1400
- },
1401
- body: JSON.stringify({ action: 'authorize', ...body }),
1402
- })
1403
-
1404
- let parsed
1405
- try {
1406
- parsed = await res.json()
1407
- } catch {
1408
- say.err(`Authorize returned non-JSON (HTTP ${res.status})`)
1409
- process.exit(1)
1410
- }
1411
-
1412
- if (!res.ok) {
1413
- // Throw a structured error so the caller can branch — 404 on a known
1414
- // siteId means "site.yml is stale, fall back to create flow" rather
1415
- // than "hard fail". Other statuses remain fatal to the caller.
1416
- const err = new Error(parsed?.error || `HTTP ${res.status}`)
1417
- err.status = res.status
1418
- throw err
1419
- }
1420
-
1421
- // The controller returns `data` wrapped by BaseController — unwrap if so.
1422
- return parsed.data ?? parsed
1423
- }
1424
-
1425
- async function callValidate({ url, token, body }) {
1426
- const res = await fetch(url, {
1427
- method: 'POST',
1428
- headers: {
1429
- 'Content-Type': 'application/json',
1430
- Authorization: `Bearer ${token}`,
1431
- },
1432
- body: JSON.stringify(body),
1433
- })
1434
- if (!res.ok) {
1435
- let err = `HTTP ${res.status}`
1436
- try {
1437
- const j = await res.json()
1438
- err = j.error || err
1439
- } catch {}
1440
- say.err(`Validate failed: ${err}`)
1441
- process.exit(1)
1442
- }
1443
- return res.json()
1444
- }
1445
-
1446
- async function callPublish({ url, token, body }) {
1447
- const res = await fetch(url, {
1448
- method: 'POST',
1449
- headers: {
1450
- 'Content-Type': 'application/json',
1451
- Authorization: `Bearer ${token}`,
1452
- },
1453
- body: JSON.stringify(body),
1454
- })
1455
- if (!res.ok) {
1456
- let err = `HTTP ${res.status}`
1457
- try {
1458
- const j = await res.json()
1459
- err = j.error || err
1460
- } catch {}
1461
- say.err(`Publish failed: ${err}`)
1462
- process.exit(1)
1463
- }
1464
- return res.json()
1465
- }
1466
-
1467
- // ─── Site-bound foundation upload (Phase 4d) ────────────────
1468
-
1469
- /**
1470
- * Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
1471
- * — the shape `POST /foundations` expects in its `files` field.
1472
- */
1473
- async function collectFoundationDistFiles(distDir) {
1474
- if (!existsSync(distDir)) {
1475
- say.err(`Foundation dist/ not found at ${distDir}.`)
1476
- process.exit(1)
1477
- }
1478
- const files = {}
1479
- const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
1480
- for (const entry of entries) {
1481
- if (!entry.isFile()) continue
1482
- const fullPath = join(entry.parentPath, entry.name)
1483
- const relPath = relative(distDir, fullPath).split(sep).join('/')
1484
- const bytes = await readFile(fullPath)
1485
- files[relPath] = bytes.toString('base64')
1486
- }
1487
- return files
1488
- }
1489
-
1490
- async function callFoundationUpload({ url, token, body }) {
1491
- const res = await fetch(url, {
1492
- method: 'POST',
1493
- headers: {
1494
- 'Content-Type': 'application/json',
1495
- Authorization: `Bearer ${token}`,
1496
- },
1497
- body: JSON.stringify(body),
1498
- })
1499
- if (!res.ok) {
1500
- let err = `HTTP ${res.status}`
1501
- try {
1502
- const j = await res.json()
1503
- err = j.error || err
1504
- } catch {}
1505
- say.err(`Foundation upload failed: ${err}`)
1506
- process.exit(1)
1507
- }
1508
- return res.json()
1509
- }
1510
-
1511
- // ─── Asset pipeline (Phase 4) ──────────────────────────────
1512
-
1513
- /**
1514
- * Walk dist/assets/*, diff against the server's manifest, upload what
1515
- * changed, and rewrite siteContent's image/document nodes to reference
1516
- * identifiers. Designed to be idempotent: on a no-change deploy, the diff
1517
- * yields zero uploads and only the rewrite runs (cheap).
1518
- *
1519
- * siteContent is mutated in place so the caller's publish payload picks up
1520
- * the rewritten nodes without passing anything back.
1521
- */
1522
- async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
1523
- const distAssetsDir = join(siteDir, 'dist', 'assets')
1524
- const hasDistAssets = existsSync(distAssetsDir)
1525
-
1526
- // 1. Enumerate local files + read size.
1527
- const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
1528
-
1529
- // 1a. Content-scan: walk site-content.json (and locale variants) for any
1530
- // asset references (image/document src/href) and resolve absolute
1531
- // paths to local files under `dist/` or `public/`. This catches static
1532
- // assets the author placed in `public/covers/`, `public/images/`, etc.
1533
- // that the dist/assets walk above misses (vite's image-pipeline only
1534
- // produces files for refs that go through it). Each resolved file
1535
- // joins the upload pipeline; the rewrite step at the end maps every
1536
- // such reference to its CDN identifier so content stays portable
1537
- // across site delete / template extraction.
1538
- const contentRefMap = await scanContentForAssetRefs(localeContents, dataFileObjects, siteDir)
1539
- const seenPaths = new Set(localFiles.map((f) => f.fullPath))
1540
- for (const [, info] of contentRefMap) {
1541
- if (seenPaths.has(info.resolvedPath)) continue
1542
- const ext = (info.filename.split('.').pop() || '').toLowerCase()
1543
- const st = await stat(info.resolvedPath)
1544
- localFiles.push({
1545
- filename: info.filename,
1546
- fullPath: info.resolvedPath,
1547
- size: st.size,
1548
- mime: MIME_BY_EXT[ext] || 'application/octet-stream',
1549
- })
1550
- seenPaths.add(info.resolvedPath)
1551
- }
1552
-
1553
- // 1a. Favicon — sits at site root, not in dist/assets. Ship it through
1554
- // the same pipeline so it ends up at assets.uniweb.app with an
1555
- // identifier; config.favicon gets set further down.
1556
- const faviconPath = await detectFavicon(siteDir, siteYml)
1557
- if (faviconPath) {
1558
- const ext = (faviconPath.split('.').pop() || '').toLowerCase()
1559
- const st = await stat(faviconPath)
1560
- localFiles.push({
1561
- filename: faviconPath.split(sep).pop(),
1562
- fullPath: faviconPath,
1563
- size: st.size,
1564
- mime: MIME_BY_EXT[ext] || 'application/octet-stream',
1565
- })
1566
- }
1567
-
1568
- // 1b. Custom fonts — scan public/fonts/<family>/<weight>-<style>.{woff,woff2}
1569
- // filtered to families actually referenced by theme slots. Each file
1570
- // enters the same upload pipeline; faces[] with CDN URLs is assembled
1571
- // below after identifiers are known.
1572
- const fontFiles = theme?.fonts?.faces
1573
- ? [] // User declared faces manually — skip auto-scan
1574
- : await discoverUsedFonts(siteDir, theme)
1575
- for (const f of fontFiles) {
1576
- localFiles.push({
1577
- filename: f.filename,
1578
- fullPath: f.fullPath,
1579
- size: f.size,
1580
- mime: MIME_BY_EXT[(f.filename.split('.').pop() || '').toLowerCase()] || 'application/octet-stream',
1581
- })
1582
- }
1583
-
1584
- if (localFiles.length === 0) {
1585
- say.dim('No assets to upload.')
1586
- return
1587
- }
1588
-
1589
- // 2. Fetch server manifest.
1590
- const server = await callAssetsAction({ backendUrl, cliToken, action: 'listAssets', body: { siteId } })
1591
- const byFilename = new Map()
1592
- for (const a of server.assets || []) byFilename.set(a.filename, a)
1593
-
1594
- // 3. Diff. Vite-hashed filenames are content-addressed (filename match →
1595
- // skip); unhashed formats fall through to size compare.
1596
- const needUpload = []
1597
- const reused = new Map() // filename → identifier (for content rewrite)
1598
- for (const f of localFiles) {
1599
- const server = byFilename.get(f.filename)
1600
- if (!server) {
1601
- needUpload.push(f)
1602
- continue
1603
- }
1604
- if (VITE_HASHED_FILENAME_RE.test(f.filename) || server.size === f.size) {
1605
- reused.set(f.filename, server.identifier)
1606
- } else {
1607
- needUpload.push(f)
1608
- }
1609
- }
1610
-
1611
- say.info(
1612
- `Assets: ${c.bold}${needUpload.length}${c.reset} to upload, ` +
1613
- `${c.bold}${reused.size}${c.reset} reused, ` +
1614
- `${c.bold}${server.assets?.length || 0}${c.reset} on server.`
1615
- )
1616
-
1617
- // 4. Plan + upload new ones.
1618
- const fresh = new Map() // filename → identifier
1619
- if (needUpload.length > 0) {
1620
- const plan = await callAssetsAction({
1621
- backendUrl, cliToken, action: 'planUploads',
1622
- body: {
1623
- siteId,
1624
- files: needUpload.map((f) => ({ filename: f.filename, size: f.size, mime: f.mime })),
1625
- },
1626
- })
1627
-
1628
- if (plan.quota) {
1629
- const usedMB = (plan.quota.usedBytes / 1048576).toFixed(1)
1630
- const addKB = (plan.quota.wouldAddBytes / 1024).toFixed(1)
1631
- say.dim(`Storage: ${usedMB} MB used (+${addKB} KB this deploy)`)
1632
- }
1633
-
1634
- const byFilenameInPlan = new Map()
1635
- for (const u of plan.uploads || []) byFilenameInPlan.set(u.filename, u)
1636
-
1637
- // Parallel upload with bounded concurrency + per-file retries.
1638
- const queue = needUpload.map((f) => ({ f, plan: byFilenameInPlan.get(f.filename) }))
1639
- const confirmed = []
1640
- const failed = []
1641
- await runInPool(queue, ASSET_UPLOAD_CONCURRENCY, async ({ f, plan }) => {
1642
- if (!plan) {
1643
- say.warn(`Server didn't return an upload plan for ${f.filename} — skipping.`)
1644
- failed.push(f.filename)
1645
- return
1646
- }
1647
- const ok = await putToS3WithRetry(f, plan.presignedPost, ASSET_UPLOAD_RETRIES)
1648
- if (ok) {
1649
- confirmed.push({ recordId: plan.recordId, filename: f.filename, identifier: plan.identifier })
1650
- } else {
1651
- failed.push(f.filename)
1652
- }
1653
- })
1654
-
1655
- if (failed.length > 0) {
1656
- say.err(`Asset upload failed for ${failed.length} file(s): ${failed.join(', ')}`)
1657
- process.exit(1)
1658
- }
1659
-
1660
- // 5. Commit successful uploads.
1661
- const confirmRes = await callAssetsAction({
1662
- backendUrl, cliToken, action: 'confirmUploads',
1663
- body: { siteId, uploaded: confirmed.map((u) => ({ recordId: u.recordId })) },
1664
- })
1665
- if ((confirmRes.failed || []).length > 0) {
1666
- say.warn(`Server couldn't confirm ${confirmRes.failed.length} upload(s). Check storage/retry.`)
1667
- }
1668
- for (const u of confirmed) fresh.set(u.filename, u.identifier)
1669
- }
1670
-
1671
- // 6. Rewrite each locale's content in place. Image/document nodes whose
1672
- // src/href references an uploaded asset get an info.identifier pointing
1673
- // to the CDN. Walking every locale means translated content (which
1674
- // still references the same image files via the source ProseMirror
1675
- // tree) gets the same rewrite.
1676
- //
1677
- // Two lookup paths:
1678
- // - byOriginalRef: full src/href string → identifier (covers static
1679
- // public/ assets like `/covers/foo.svg` and dist/-resolved refs)
1680
- // - byFilename: legacy match for `assets/{filename}` shape — kept
1681
- // for back-compat with content authored against the old vite-
1682
- // produced `/assets/...` URLs.
1683
- const byFilenameAll = new Map([...reused, ...fresh])
1684
- const byOriginalRef = new Map()
1685
- for (const [ref, info] of contentRefMap) {
1686
- const id = byFilenameAll.get(info.filename)
1687
- if (id) byOriginalRef.set(ref, id)
1688
- }
1689
- let rewritten = 0
1690
- for (const lang of Object.keys(localeContents)) {
1691
- rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll, byOriginalRef)
1692
- }
1693
- // Data files: walk the JSON tree. Two patterns coexist in collection
1694
- // payloads:
1695
- // - Flat fields (e.g. `article.image: "/covers/foo.svg"`) → replace
1696
- // the string with a resolveAssetCdnUrl(identifier). The runtime
1697
- // reads these as plain URLs, so rewriting at deploy time is the
1698
- // simplest path to portability.
1699
- // - Nested ProseMirror sub-trees (e.g. `article.content`) → use the
1700
- // existing image/document node rewrite (sets `attrs.info.identifier`).
1701
- for (const k of Object.keys(dataFileObjects)) {
1702
- if (dataFileObjects[k] === null) continue
1703
- rewritten += rewriteFlatAssetUrls(dataFileObjects[k], byOriginalRef)
1704
- rewritten += rewriteAssetReferences(dataFileObjects[k], byFilenameAll, byOriginalRef)
1705
- }
1706
- if (rewritten > 0) {
1707
- say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
1708
- }
1709
-
1710
- // 7. If a favicon was included above, inject its resolved CDN URL into
1711
- // every locale's config.favicon. Matches Editor publish (which sets
1712
- // favicon per-locale); Worker bakes <link rel="icon"> from the active
1713
- // locale's content.config.favicon.
1714
- if (faviconPath) {
1715
- const favName = faviconPath.split(sep).pop()
1716
- const favIdentifier = byFilenameAll.get(favName)
1717
- if (favIdentifier) {
1718
- const faviconUrl = resolveAssetCdnUrl(favIdentifier)
1719
- for (const lang of Object.keys(localeContents)) {
1720
- localeContents[lang].config = { ...(localeContents[lang].config || {}), favicon: faviconUrl }
1721
- }
1722
- say.dim(`Favicon: ${favName}`)
1723
- }
1724
- }
1725
-
1726
- // 8. Assemble theme.fonts.faces from uploaded font files. Replaces the
1727
- // local /fonts/... src with the CDN URL for each identifier. Mirrors
1728
- // unicloud's scanFontDirectory → faces[] shape so @uniweb/theming
1729
- // emits @font-face + preload links without any other changes.
1730
- if (fontFiles.length > 0) {
1731
- const faces = []
1732
- for (const f of fontFiles) {
1733
- const identifier = byFilenameAll.get(f.filename)
1734
- if (!identifier) continue
1735
- faces.push({
1736
- family: f.family,
1737
- src: resolveAssetCdnUrl(identifier),
1738
- weight: f.weight,
1739
- style: f.style,
1740
- format: f.format,
1741
- })
1742
- }
1743
- if (faces.length > 0) {
1744
- theme.fonts = { ...(theme.fonts || {}), faces }
1745
- const families = [...new Set(faces.map((x) => x.family))].join(', ')
1746
- say.dim(`Fonts: ${faces.length} face(s) across ${families}`)
1747
- }
1748
- }
1749
- }
1750
-
1751
- async function walkAssetDir(dir) {
1752
- const out = []
1753
- const entries = await readdir(dir, { withFileTypes: true, recursive: true })
1754
- for (const entry of entries) {
1755
- if (!entry.isFile()) continue
1756
- const ext = (entry.name.split('.').pop() || '').toLowerCase()
1757
- // Only upload media. JS/CSS/JSON/map files in dist/assets/ are Vite's
1758
- // build output — the Worker serves the site via runtime/{version}/ +
1759
- // content injection, not from these chunks.
1760
- if (!MEDIA_EXTENSIONS.has(ext)) continue
1761
- const fullPath = join(entry.parentPath || entry.path, entry.name)
1762
- const st = await stat(fullPath)
1763
- out.push({
1764
- filename: entry.name,
1765
- fullPath,
1766
- size: st.size,
1767
- mime: MIME_BY_EXT[ext] || 'application/octet-stream',
1768
- })
1769
- }
1770
- return out
1771
- }
1772
-
1773
- // Detect the site's favicon on disk. Order: explicit `favicon:` in site.yml,
1774
- // then any of favicon.{svg,ico,png,webp} at the site root. Returns null when
1775
- // nothing is found (site serves without a favicon).
1776
- async function detectFavicon(siteDir, siteYml) {
1777
- if (typeof siteYml?.favicon === 'string' && siteYml.favicon.trim()) {
1778
- const p = resolve(siteDir, siteYml.favicon.trim())
1779
- if (existsSync(p)) return p
1780
- say.warn(`site.yml favicon "${siteYml.favicon}" not found on disk — falling back to auto-detect.`)
1781
- }
1782
- // Check both the site root and Vite's public/ directory (public/* is the
1783
- // source for static assets copied verbatim into dist/ at build time).
1784
- const dirs = [siteDir, join(siteDir, 'public')]
1785
- for (const dir of dirs) {
1786
- for (const name of ['favicon.svg', 'favicon.ico', 'favicon.png', 'favicon.webp']) {
1787
- const p = join(dir, name)
1788
- if (existsSync(p)) return p
1789
- }
1790
- }
1791
- return null
1792
- }
1793
-
1794
- // Named weight → CSS numeric weight. Matches unicloud's font-scanner.js so
1795
- // the CLI-deploy path and the local unicloud dev path agree on conventions.
1796
- const FONT_WEIGHT_MAP = {
1797
- thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300,
1798
- normal: 400, regular: 400, medium: 500, semibold: 600, demibold: 600,
1799
- bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900,
1800
- }
1801
-
1802
- // Parse "bold-normal.woff2" / "400-italic.woff" style filenames into weight,
1803
- // style, format. Returns null on any unrecognized shape (caller skips the file).
1804
- function parseFontFilename(filename) {
1805
- const dotIdx = filename.lastIndexOf('.')
1806
- if (dotIdx === -1) return null
1807
- const ext = filename.slice(dotIdx + 1).toLowerCase()
1808
- if (ext !== 'woff' && ext !== 'woff2') return null
1809
- const format = ext === 'woff2' ? 'woff2' : 'woff'
1810
- const stem = filename.slice(0, dotIdx)
1811
- const parts = stem.split('-')
1812
- if (parts.length < 2) return null
1813
- const style = parts[parts.length - 1].toLowerCase()
1814
- if (style !== 'normal' && style !== 'italic') return null
1815
- const weightPart = parts.slice(0, -1).join('').toLowerCase()
1816
- const numWeight = parseInt(weightPart, 10)
1817
- if (!isNaN(numWeight) && numWeight >= 1 && numWeight <= 999) {
1818
- return { weight: numWeight, style, format }
1819
- }
1820
- const mapped = FONT_WEIGHT_MAP[weightPart]
1821
- if (mapped) return { weight: mapped, style, format }
1822
- return null
1823
- }
1824
-
1825
- // Extract the set of lowercase family names referenced by theme slots
1826
- // (heading/body/mono and any declared _userSlots). Mirrors
1827
- // @uniweb/theming's extractUsedFamilies — used here to drop font files
1828
- // for families the theme doesn't actually consume, so upload stays lean.
1829
- function extractUsedFontFamilies(theme) {
1830
- const fonts = theme?.fonts || {}
1831
- const slots = fonts._userSlots || ['body', 'heading', 'mono']
1832
- const generic = new Set([
1833
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
1834
- 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
1835
- ])
1836
- const used = new Set()
1837
- for (const slot of slots) {
1838
- const v = fonts[slot]
1839
- if (typeof v !== 'string') continue
1840
- for (const seg of v.split(',')) {
1841
- const n = seg.trim().replace(/^["']|["']$/g, '').toLowerCase()
1842
- if (n && !generic.has(n)) used.add(n)
1843
- }
1844
- }
1845
- return used
1846
- }
1847
-
1848
- // Scan public/fonts/<family>/<weight>-<style>.{woff,woff2} and return the
1849
- // files belonging to families that the theme actually uses. Returning [] is
1850
- // the normal case for sites that don't ship custom fonts.
1851
- async function discoverUsedFonts(siteDir, theme) {
1852
- const fontsDir = join(siteDir, 'public', 'fonts')
1853
- if (!existsSync(fontsDir)) return []
1854
- const used = extractUsedFontFamilies(theme)
1855
- if (used.size === 0) return []
1856
-
1857
- let familyDirs
1858
- try {
1859
- familyDirs = await readdir(fontsDir, { withFileTypes: true })
1860
- } catch {
1861
- return []
1862
- }
1863
-
1864
- const out = []
1865
- for (const entry of familyDirs) {
1866
- if (!entry.isDirectory()) continue
1867
- const family = entry.name.toLowerCase()
1868
- if (!used.has(family)) continue // Skip unreferenced families.
1869
- const familyDir = join(fontsDir, entry.name)
1870
- let files
1871
- try {
1872
- files = await readdir(familyDir, { withFileTypes: true })
1873
- } catch { continue }
1874
- for (const file of files) {
1875
- if (!file.isFile()) continue
1876
- const parsed = parseFontFilename(file.name)
1877
- if (!parsed) continue
1878
- const fullPath = join(familyDir, file.name)
1879
- const st = await stat(fullPath)
1880
- out.push({
1881
- filename: file.name,
1882
- fullPath,
1883
- size: st.size,
1884
- family,
1885
- weight: parsed.weight,
1886
- style: parsed.style,
1887
- format: parsed.format,
1888
- })
1889
- }
1890
- }
1891
- return out
1892
- }
1893
-
1894
- // Resolve an asset identifier ({uuid}/{filename}) to the canonical CDN URL.
1895
- // Mirrors `resolveAssetIdentifier` in @uniweb/semantic-parser so the favicon
1896
- // URL shape matches everything else the Worker sees from Editor publishes.
1897
- function resolveAssetCdnUrl(identifier) {
1898
- if (!identifier || typeof identifier !== 'string') return ''
1899
- const [uuid, filename] = identifier.split('/')
1900
- if (!filename) return ''
1901
- const ext = filename.substring(filename.lastIndexOf('.') + 1)
1902
- return `https://assets.uniweb.app/dist/${uuid}/base.${ext}`
1903
- }
1904
-
1905
- async function callAssetsAction({ backendUrl, cliToken, action, body }) {
1906
- const res = await fetch(`${backendUrl}/cli-assets.php`, {
1907
- method: 'POST',
1908
- headers: {
1909
- 'Content-Type': 'application/json',
1910
- Authorization: `Bearer ${cliToken}`,
1911
- },
1912
- body: JSON.stringify({ action, ...body }),
1913
- })
1914
- let parsed
1915
- try { parsed = await res.json() } catch {
1916
- throw new Error(`cli-assets.${action} returned non-JSON (HTTP ${res.status})`)
1917
- }
1918
- if (!res.ok) {
1919
- throw new Error(parsed?.error || `cli-assets.${action} failed (HTTP ${res.status})`)
1920
- }
1921
- return parsed.data ?? parsed
1922
- }
1923
-
1924
- /**
1925
- * POST a single file to S3 via a pre-signed POST. Retries transient
1926
- * failures (network errors + 5xx) up to `maxRetries` times before giving up.
1927
- * S3 pre-signed POSTs don't support resumable upload, so each retry is a
1928
- * full re-POST. File sizes are <= 50 MB so that's tolerable.
1929
- */
1930
- async function putToS3WithRetry(file, presigned, maxRetries) {
1931
- const body = await readFile(file.fullPath)
1932
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1933
- try {
1934
- // Node's FormData doesn't produce what S3 wants — build a multipart
1935
- // body manually using fetch's standard FormData, giving us File-like
1936
- // semantics via Blob.
1937
- const form = new FormData()
1938
- for (const [k, v] of Object.entries(presigned.fields)) form.append(k, String(v))
1939
- form.append('file', new Blob([body], { type: file.mime }), file.filename)
1940
-
1941
- const res = await fetch(presigned.url, { method: 'POST', body: form })
1942
- if (res.ok || res.status === 204) return true
1943
- if (res.status >= 500 && attempt < maxRetries) continue
1944
- // Surface the server's response so failures are diagnosable. S3
1945
- // returns XML with a useful <Code>/<Message> on rejection (e.g.
1946
- // AccessDenied + reason); silently retrying without surfacing it
1947
- // hides real config issues like bucket-permission mismatches.
1948
- const errBody = await res.text().catch(() => '')
1949
- say.warn(`Upload of ${file.filename} rejected by S3 (HTTP ${res.status}):\n ${errBody.slice(0, 500)}`)
1950
- return false
1951
- } catch (err) {
1952
- if (attempt < maxRetries) continue
1953
- say.warn(`Upload of ${file.filename} failed: ${err?.message || err}`)
1954
- return false
1955
- }
1956
- }
1957
- return false
1958
- }
1959
-
1960
- /**
1961
- * Run up to `concurrency` promises at a time from `items`. Returns when all
1962
- * settle. Propagates errors as thrown (caller wraps in try/catch if needed)
1963
- * — but the worker here swallows per-item errors and collects them instead.
1964
- */
1965
- async function runInPool(items, concurrency, worker) {
1966
- let i = 0
1967
- const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1968
- while (i < items.length) {
1969
- const idx = i++
1970
- await worker(items[idx])
1971
- }
1972
- })
1973
- await Promise.all(runners)
1974
- }
1975
-
1976
- /**
1977
- * Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
1978
- * `attrs.src` or `attrs.href` references an uploaded/reused asset. Sets
1979
- * `attrs.info.identifier` so semantic-parser resolves the real CDN URL
1980
- * (and optimized variants) at render time.
1981
- *
1982
- * Two lookup paths, in order:
1983
- * 1. `byOriginalRef` — full src/href string → identifier. Covers static
1984
- * public/ assets (`/covers/foo.svg`, `/images/foo.png`) and any
1985
- * content-scan-resolved file. Decouples assets from site lifecycle
1986
- * (templates can extract content + identifier; assets stay on CDN).
1987
- * 2. `byFilename` (legacy) — only fires when the path matches the old
1988
- * `/assets/{filename}` shape. Kept so re-deploys of content authored
1989
- * against pre-content-scan CLIs still work.
1990
- *
1991
- * Returns the number of rewrites performed — useful for reporting, and to
1992
- * detect "nothing matched" (likely a content-shape mismatch worth flagging).
1993
- */
1994
- function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
1995
- let count = 0
1996
- const walk = (n) => {
1997
- if (!n || typeof n !== 'object') return
1998
- if (Array.isArray(n)) { for (const child of n) walk(child); return }
1999
- if (n.attrs && typeof n.attrs === 'object') {
2000
- // Prefer full-ref lookup (covers static + dist refs uniformly);
2001
- // fall back to legacy `assets/{filename}` extraction.
2002
- let identifier = null
2003
- let srcMatched = false
2004
- let hrefMatched = false
2005
- if (typeof n.attrs.src === 'string' && byOriginalRef.has(n.attrs.src)) {
2006
- identifier = byOriginalRef.get(n.attrs.src)
2007
- srcMatched = true
2008
- } else if (typeof n.attrs.href === 'string' && byOriginalRef.has(n.attrs.href)) {
2009
- identifier = byOriginalRef.get(n.attrs.href)
2010
- hrefMatched = true
2011
- } else {
2012
- const srcRef = pickAssetRef(n.attrs.src)
2013
- const hrefRef = pickAssetRef(n.attrs.href)
2014
- const ref = srcRef || hrefRef
2015
- if (ref) {
2016
- identifier = byFilename.get(ref) || null
2017
- srcMatched = !!srcRef
2018
- hrefMatched = !srcRef && !!hrefRef
2019
- }
2020
- }
2021
- if (identifier) {
2022
- n.attrs.info = {
2023
- ...(n.attrs.info || {}),
2024
- identifier,
2025
- contentType: 'website',
2026
- viewType: 'profile',
2027
- }
2028
- // Clear the local path so the runtime resolves via info.identifier
2029
- // (→ assets.uniweb.app CDN) instead of requesting a non-existent
2030
- // file from the site host.
2031
- if (srcMatched) n.attrs.src = null
2032
- if (hrefMatched) n.attrs.href = null
2033
- // Match the Editor shape: plain `image` nodes skip identifier
2034
- // resolution in older runtimes; `ImageBlock` routes through
2035
- // parseImgBlock which reads info.identifier and fills url.
2036
- if (n.type === 'image' && n.attrs.role !== 'icon') {
2037
- n.type = 'ImageBlock'
2038
- }
2039
- count++
2040
- }
2041
- }
2042
- for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
2043
- }
2044
- walk(node)
2045
- return count
2046
- }
2047
-
2048
- function pickAssetRef(v) {
2049
- if (typeof v !== 'string') return null
2050
- // Match "/assets/filename.ext", "./assets/filename.ext", "assets/filename.ext".
2051
- const m = v.match(/(?:^|\/|\.\/)assets\/([^/?#]+)$/)
2052
- return m ? m[1] : null
2053
- }
2054
-
2055
- /**
2056
- * Walk every locale's content for `attrs.src` and `attrs.href` strings, and
2057
- * resolve absolute-path refs (e.g. `/covers/foo.svg`) to local files under
2058
- * the site root.
2059
- *
2060
- * Resolution order per ref:
2061
- * 1. `dist/{path}` — vite outputs, link-mode collection JSON, etc.
2062
- * 2. `public/{path}` — static author-placed assets (covers, images).
2063
- *
2064
- * Returns Map<originalRef, { resolvedPath, filename }> where:
2065
- * - `originalRef` — the exact src/href string from content (used as the
2066
- * lookup key during rewrite).
2067
- * - `resolvedPath` — absolute path on disk (used for upload).
2068
- * - `filename` — basename, used as the assets-server upload filename.
2069
- * Server keys by (siteId, filename); collisions across
2070
- * paths with the same basename are flagged as warnings.
2071
- *
2072
- * Skips:
2073
- * - Non-string values, refs that don't start with `/`, protocol-relative
2074
- * refs (`//cdn.example.com/...`), and external URLs.
2075
- * - Refs starting with `/api/` or `/_` (worker-internal paths, never
2076
- * local files).
2077
- * - Nodes already rewritten with `attrs.info.identifier` set (re-deploy).
2078
- */
2079
- async function scanContentForAssetRefs(localeContents, dataFileObjects, siteDir) {
2080
- const candidates = new Set()
2081
- for (const lang of Object.keys(localeContents)) {
2082
- walkContentForAssetRefs(localeContents[lang], candidates)
2083
- }
2084
- // Also walk parsed collection JSON files. These contain BOTH ProseMirror-
2085
- // shaped sub-trees (article.content) AND flat string fields (article.image,
2086
- // article.cover, etc.). The walker captures both: any string-valued src/
2087
- // href/image/cover/thumbnail/icon/poster field, plus any string anywhere
2088
- // that looks like an absolute path with a known media extension.
2089
- for (const k of Object.keys(dataFileObjects || {})) {
2090
- if (dataFileObjects[k] !== null) {
2091
- walkContentForAssetRefs(dataFileObjects[k], candidates)
2092
- }
2093
- }
2094
-
2095
- const results = new Map()
2096
- const filenameToRef = new Map() // detect collisions (same basename, different path)
2097
- for (const ref of candidates) {
2098
- if (!isResolvableContentRef(ref)) continue
2099
- const cleanPath = ref.split('?')[0].split('#')[0].slice(1) // drop leading '/'
2100
- const distCandidate = join(siteDir, 'dist', cleanPath)
2101
- const publicCandidate = join(siteDir, 'public', cleanPath)
2102
- let resolvedPath = null
2103
- if (existsSync(distCandidate)) {
2104
- try { if ((await stat(distCandidate)).isFile()) resolvedPath = distCandidate } catch {}
2105
- }
2106
- if (!resolvedPath && existsSync(publicCandidate)) {
2107
- try { if ((await stat(publicCandidate)).isFile()) resolvedPath = publicCandidate } catch {}
2108
- }
2109
- if (!resolvedPath) continue
2110
- const filename = resolvedPath.split(sep).pop()
2111
- const prior = filenameToRef.get(filename)
2112
- if (prior && prior !== resolvedPath) {
2113
- // Two different files want the same upload filename — server keys by
2114
- // filename so the second would clobber the first. Skip + warn rather
2115
- // than silently overwrite. Caller can rename the file or move one
2116
- // into a vite-processed path to disambiguate via content hashing.
2117
- say.warn(
2118
- `Asset filename collision: "${filename}" exists at multiple paths ` +
2119
- `(${prior}, ${resolvedPath}). Skipping the second; rename to disambiguate.`
2120
- )
2121
- continue
2122
- }
2123
- filenameToRef.set(filename, resolvedPath)
2124
- results.set(ref, { resolvedPath, filename })
2125
- }
2126
- return results
2127
- }
2128
-
2129
- // Field names commonly used for media in collection JSON. The walker
2130
- // collects any absolute-path string under these keys as a potential asset
2131
- // reference. ProseMirror image/link nodes are caught separately via attrs.
2132
- const FLAT_ASSET_FIELDS = new Set([
2133
- 'src', 'href', 'image', 'cover', 'thumbnail', 'icon', 'poster', 'logo',
2134
- 'avatar', 'photo', 'banner', 'background',
2135
- ])
2136
-
2137
- function walkContentForAssetRefs(node, refs) {
2138
- if (!node || typeof node !== 'object') return
2139
- if (Array.isArray(node)) { for (const child of node) walkContentForAssetRefs(child, refs); return }
2140
- if (node.attrs && typeof node.attrs === 'object') {
2141
- // Skip nodes already rewritten in a prior deploy — those have an
2142
- // identifier and the runtime resolves them through the CDN already.
2143
- if (!node.attrs.info?.identifier) {
2144
- if (typeof node.attrs.src === 'string') refs.add(node.attrs.src)
2145
- if (typeof node.attrs.href === 'string') refs.add(node.attrs.href)
2146
- }
2147
- }
2148
- // Flat fields: collection-shaped objects (e.g. an article record) often
2149
- // carry media URLs as plain string fields rather than ProseMirror nodes.
2150
- // Capture absolute-path values under known keys.
2151
- for (const [k, v] of Object.entries(node)) {
2152
- if (typeof v === 'string' && FLAT_ASSET_FIELDS.has(k) && isResolvableContentRef(v)) {
2153
- refs.add(v)
2154
- } else if (typeof v === 'object') {
2155
- walkContentForAssetRefs(v, refs)
2156
- }
2157
- }
2158
- }
2159
-
2160
- /**
2161
- * Walk an arbitrary JSON tree and replace any string equal to a key in
2162
- * `byOriginalRef` (and not already a CDN URL) with the asset's CDN URL.
2163
- * Used for collection JSON files where image refs are flat string fields
2164
- * (e.g. `article.image: "/covers/foo.svg"`) rather than ProseMirror nodes.
2165
- *
2166
- * Returns the number of replacements performed.
2167
- */
2168
- function rewriteFlatAssetUrls(node, byOriginalRef) {
2169
- let count = 0
2170
- const walk = (n, parent, key) => {
2171
- if (n == null) return
2172
- if (typeof n === 'string') {
2173
- const id = byOriginalRef.get(n)
2174
- if (id && parent != null && key != null) {
2175
- parent[key] = resolveAssetCdnUrl(id)
2176
- count++
2177
- }
2178
- return
2179
- }
2180
- if (typeof n !== 'object') return
2181
- if (Array.isArray(n)) {
2182
- for (let i = 0; i < n.length; i++) walk(n[i], n, i)
2183
- return
2184
- }
2185
- for (const [k, v] of Object.entries(n)) walk(v, n, k)
2186
- }
2187
- walk(node, null, null)
2188
- return count
2189
- }
2190
-
2191
- function isResolvableContentRef(ref) {
2192
- if (typeof ref !== 'string' || !ref) return false
2193
- // Absolute-path only — relative paths (`./foo`, `foo`) are content-author
2194
- // shorthand handled elsewhere; URLs (`http://`, `//cdn`) never resolve to
2195
- // local files; worker-internal paths (`/api/`, `/_`) aren't asset content.
2196
- if (!ref.startsWith('/')) return false
2197
- if (ref.startsWith('//')) return false
2198
- if (ref.startsWith('/api/') || ref.startsWith('/_')) return false
2199
- return true
2200
- }
2201
-
2202
- // ─── Loopback listener (review path) ───────────────────────
2203
-
2204
- /**
2205
- * Start an HTTP server on a random loopback port to receive the publish
2206
- * token from the browser. The server accepts ONE request to /callback; after
2207
- * that it's closed.
2208
- *
2209
- * Same shape as `login.js::browserLogin`, but POST-accepting since the web
2210
- * app POSTs JSON (not a redirect with query params like CliAuthController).
2211
- */
2212
- async function startLoopback() {
2213
- return new Promise((resolveReady) => {
2214
- let resolveCallback
2215
- const callbackPromise = new Promise((r) => { resolveCallback = r })
2216
-
2217
- const server = createServer((req, res) => {
2218
- const u = new URL(req.url, 'http://localhost')
2219
- if (u.pathname !== '/callback') {
2220
- res.writeHead(404)
2221
- res.end('Not found')
2222
- return
2223
- }
2224
-
2225
- // CORS preflight — the web app POSTs JSON cross-origin, so browsers
2226
- // send an OPTIONS preflight first. Respond with permissive CORS headers.
2227
- if (req.method === 'OPTIONS') {
2228
- res.writeHead(204, {
2229
- 'Access-Control-Allow-Origin': '*',
2230
- 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
2231
- 'Access-Control-Allow-Headers': 'Content-Type',
2232
- 'Access-Control-Max-Age': '3600',
2233
- })
2234
- res.end()
2235
- return
2236
- }
2237
-
2238
- // Accept POST (web app posts JSON) or GET (browser redirect with params)
2239
- if (req.method === 'POST') {
2240
- let buf = ''
2241
- req.on('data', (chunk) => (buf += chunk))
2242
- req.on('end', () => {
2243
- let payload = {}
2244
- try { payload = JSON.parse(buf) } catch {}
2245
- respondSuccess(res)
2246
- resolveCallback(payload)
2247
- })
2248
- return
2249
- }
2250
- if (req.method === 'GET') {
2251
- const publishToken = u.searchParams.get('token')
2252
- const siteId = u.searchParams.get('siteId')
2253
- const handle = u.searchParams.get('handle')
2254
- if (!publishToken) {
2255
- res.writeHead(400, { 'Content-Type': 'text/html' })
2256
- res.end('<h2>Missing token</h2>')
2257
- return
2258
- }
2259
- respondSuccess(res)
2260
- resolveCallback({ publishToken, siteId, handle })
2261
- return
2262
- }
2263
- res.writeHead(405)
2264
- res.end('Method not allowed')
2265
- })
2266
-
2267
- server.listen(0, '127.0.0.1', () => {
2268
- const port = server.address().port
2269
- resolveReady({
2270
- callbackUrl: `http://127.0.0.1:${port}/callback`,
2271
- waitForCallback: (timeoutMs) => Promise.race([
2272
- callbackPromise,
2273
- new Promise((r) => setTimeout(() => r(null), timeoutMs)),
2274
- ]),
2275
- close: () => { try { server.close() } catch {} },
2276
- })
2277
- })
2278
- })
2279
- }
2280
-
2281
- function respondSuccess(res) {
2282
- // CORS preflight + actual response, since the web app POSTs cross-origin.
2283
- res.writeHead(200, {
2284
- 'Content-Type': 'text/html; charset=utf-8',
2285
- 'Access-Control-Allow-Origin': '*',
2286
- })
2287
- res.end(
2288
- '<html><body style="font-family:system-ui;text-align:center;padding:60px">' +
2289
- '<h2 style="color:#16a34a">Deploy authorized</h2>' +
2290
- '<p>You can close this window and return to your terminal.</p>' +
2291
- '</body></html>'
2292
- )
2293
- }
2294
-
2295
- async function openBrowser(url) {
2296
- try {
2297
- const { exec } = await import('node:child_process')
2298
- const cmd = process.platform === 'darwin'
2299
- ? `open "${url}"`
2300
- : process.platform === 'win32'
2301
- ? `start "" "${url}"`
2302
- : `xdg-open "${url}"`
2303
- return new Promise((r) => exec(cmd, (err) => r(!err)))
2304
- } catch {
2305
- return false
2306
- }
2307
- }
2308
-
2309
- export default deploy