uniweb 0.10.4 → 0.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,89 +1,391 @@
1
1
  /**
2
2
  * Deploy Command
3
3
  *
4
- * Deploys a built site to Uniweb hosting.
4
+ * Deploys a built site to Uniweb hosting. Phase 1 — link-mode, content + theme
5
+ * + locales only (no binary assets yet).
6
+ *
7
+ * Flow:
8
+ * 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
9
+ * 2. Resolve runtime (default: GET /api/runtime/latest from the Worker).
10
+ * 3. ensureAuth() → bearer CLI JWT from ~/.uniweb/auth.json.
11
+ * 4. Build `dist/` if missing.
12
+ * 5. Load dist/site-content.json → extract `languages` for the capability
13
+ * preview.
14
+ * 6. Start an ephemeral loopback listener for the browser-callback path.
15
+ * 7. POST PHP /cli-deploy.php?action=authorize with { siteId?, foundation,
16
+ * runtimeVersion, languages, callbackUrl }.
17
+ * 8. Branch:
18
+ * - publishToken returned → fast path.
19
+ * - needsReview:true + reviewUrl → open browser, wait for callback,
20
+ * consume { publishToken, siteId, handle }.
21
+ * 9. POST Worker /api/publish/validate to confirm foundation + runtime
22
+ * exist and the token's namespace claim matches.
23
+ * 10. POST Worker /api/publish/process with the full payload.
24
+ * 11. On first-deploy create flow: write site.id + site.handle back into
25
+ * site.yml so subsequent deploys fast-path.
5
26
  *
6
27
  * Usage:
7
- * uniweb deploy # Deploy to Uniweb hosting
8
- * uniweb deploy --local # Deploy to local server (no auth)
9
- * uniweb deploy --registry <url> # Deploy to a specific server URL
10
- * uniweb deploy --dry-run # Show what would be deployed
11
- * uniweb deploy --prod # Deploy to production (future)
28
+ * uniweb deploy Normal deploy (browser may open on first deploy)
29
+ * uniweb deploy --skip-build Don't rebuild even if dist/ is stale
30
+ * uniweb deploy --dry-run Resolve everything but skip the Worker POST
31
+ * uniweb deploy --skip-billing Admin-only: bypass billing gate (dev/testing)
32
+ *
33
+ * See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
12
34
  */
13
35
 
36
+ import { createServer } from 'node:http'
14
37
  import { existsSync } from 'node:fs'
15
- import { readFile, readdir } from 'node:fs/promises'
16
- import { resolve, join, basename, relative } from 'node:path'
38
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises'
39
+ import { resolve, join, basename, sep } from 'node:path'
17
40
  import { execSync } from 'node:child_process'
41
+ import yaml from 'js-yaml'
18
42
 
19
43
  import { ensureAuth } from '../utils/auth.js'
20
- import { findWorkspaceRoot, findSites, findFoundations, classifyPackage, promptSelect } from '../utils/workspace.js'
44
+ import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
45
+ import {
46
+ findWorkspaceRoot,
47
+ findSites,
48
+ classifyPackage,
49
+ promptSelect,
50
+ } from '../utils/workspace.js'
21
51
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
22
52
 
23
- // Colors for terminal output
24
- const colors = {
53
+ const REVIEW_TIMEOUT_MS = 15 * 60 * 1000 // 15 min matches PHP session TTL
54
+ const ASSET_UPLOAD_CONCURRENCY = 6
55
+ const ASSET_UPLOAD_RETRIES = 2
56
+ // Vite content-addresses these formats. Same filename → same content, so we
57
+ // can skip upload without checking size. Unhashed formats fall through to
58
+ // size-compare diffing.
59
+ const VITE_HASHED_FILENAME_RE = /-[0-9a-f]{8,}\.[a-z0-9]+$/i
60
+
61
+ // MEDIA extensions only — images, fonts, documents, video/audio. dist/assets/
62
+ // also contains Vite's JS/CSS chunks and source maps, which are code, not
63
+ // user media, and are served by the Worker from elsewhere (runtime bundle +
64
+ // content injection). Uploading those is wasted storage — they're never
65
+ // referenced. Mirror of ProfileAsset's ALLOWED_EXTENSIONS minus the text
66
+ // formats that have no place in a static media bucket.
67
+ const MEDIA_EXTENSIONS = new Set([
68
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico',
69
+ 'pdf', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'xlsm', 'xlsb',
70
+ 'mp4', 'webm', 'ogg',
71
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
72
+ ])
73
+ const MIME_BY_EXT = {
74
+ webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
75
+ gif: 'image/gif', svg: 'image/svg+xml', ico: 'image/x-icon',
76
+ pdf: 'application/pdf',
77
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
78
+ eot: 'application/vnd.ms-fontobject',
79
+ mp4: 'video/mp4', webm: 'video/webm', ogg: 'audio/ogg',
80
+ }
81
+
82
+ const c = {
25
83
  reset: '\x1b[0m',
26
- bright: '\x1b[1m',
84
+ bold: '\x1b[1m',
27
85
  dim: '\x1b[2m',
28
86
  cyan: '\x1b[36m',
29
87
  green: '\x1b[32m',
30
88
  yellow: '\x1b[33m',
31
89
  red: '\x1b[31m',
32
90
  }
33
-
34
- function success(message) {
35
- console.log(`${colors.green}✓${colors.reset} ${message}`)
91
+ const say = {
92
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
93
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
94
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
95
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
96
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
36
97
  }
37
98
 
38
- function error(message) {
39
- console.error(`${colors.red}✗${colors.reset} ${message}`)
99
+ // ─── Main ───────────────────────────────────────────────────
100
+
101
+ export async function deploy(args = []) {
102
+ const skipBuild = args.includes('--skip-build')
103
+ const dryRun = args.includes('--dry-run')
104
+ const skipAssets = args.includes('--skip-assets')
105
+ const skipBilling = args.includes('--skip-billing')
106
+
107
+ const siteDir = await resolveSiteDir(args)
108
+ const backendUrl = getBackendUrl()
109
+ const workerUrl = getRegistryUrl()
110
+
111
+ // Read site.yml — declares the foundation (required) and optionally the
112
+ // site.id / site.handle from prior deploys.
113
+ const siteYmlPath = join(siteDir, 'site.yml')
114
+ const siteYml = await readSiteYml(siteYmlPath)
115
+ const foundation = siteYml.foundation
116
+ if (!foundation) {
117
+ say.err('site.yml is missing `foundation`.')
118
+ say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
119
+ process.exit(1)
120
+ }
121
+
122
+ // Runtime defaults to "latest" resolved at authorize time.
123
+ let runtimeVersion = siteYml.runtime
124
+ if (!runtimeVersion) {
125
+ runtimeVersion = await fetchLatestRuntime(workerUrl)
126
+ if (!runtimeVersion) {
127
+ say.err('Could not resolve a runtime version (no runtime: in site.yml, /api/runtime/latest failed).')
128
+ process.exit(1)
129
+ }
130
+ say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
131
+ }
132
+
133
+ const cliToken = await ensureAuth({ command: 'Deploying' })
134
+
135
+ // Always rebuild unless the user explicitly opts out with --skip-build.
136
+ // A stale dist/ from a previous build + edited content on disk would
137
+ // otherwise silently ship yesterday's version — a footgun big enough
138
+ // to warrant the extra seconds every deploy.
139
+ const distDir = join(siteDir, 'dist')
140
+ const contentPath = join(distDir, 'site-content.json')
141
+ if (!skipBuild) {
142
+ say.info('Building site…')
143
+ console.log('')
144
+ // No VITE_FOUNDATION_MODE override needed: @uniweb/build's
145
+ // detectFoundationType recognizes `@ns/name@version` refs as
146
+ // link-mode URLs, which auto-enters runtime mode. Prerender also
147
+ // auto-skips for link-mode foundations (HTML is rendered on the
148
+ // serving edge, not here).
149
+ execSync('npx uniweb build', {
150
+ cwd: siteDir,
151
+ stdio: 'inherit',
152
+ })
153
+ console.log('')
154
+ } else if (!existsSync(contentPath)) {
155
+ say.err('No build found and --skip-build passed. Run `uniweb build` first.')
156
+ process.exit(1)
157
+ }
158
+ if (!existsSync(contentPath)) {
159
+ say.err('Build did not produce dist/site-content.json')
160
+ process.exit(1)
161
+ }
162
+
163
+ // Read site-content.json — we need `languages` for the capability preview
164
+ // and the whole object for the publish payload.
165
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
166
+ const languages = extractLanguages(siteContent)
167
+ const defaultLanguage = siteContent?.config?.defaultLanguage || languages[0] || 'en'
168
+ const theme = await readTheme(siteDir, siteContent)
169
+
170
+ if (dryRun) {
171
+ say.info('Dry run — showing what would be deployed:')
172
+ say.dim(`Site dir : ${siteDir}`)
173
+ say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
174
+ say.dim(`Foundation : ${foundation}`)
175
+ say.dim(`Runtime : ${runtimeVersion}`)
176
+ say.dim(`Languages : ${languages.join(', ')}`)
177
+ say.dim(`Default locale : ${defaultLanguage}`)
178
+ say.dim(`Backend (PHP) : ${backendUrl}`)
179
+ say.dim(`Worker : ${workerUrl}`)
180
+ return
181
+ }
182
+
183
+ // Spin up the loopback listener eagerly — we need its callback URL for the
184
+ // authorize request even on the fast path (PHP may always return
185
+ // needsReview=true on first deploy / billing drift in future phases).
186
+ const loopback = await startLoopback()
187
+
188
+ let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl
189
+ try {
190
+ say.info('Requesting deploy authorization…')
191
+ const authorizeBody = {
192
+ siteId: siteYml.site?.id || '',
193
+ foundation,
194
+ runtimeVersion,
195
+ languages,
196
+ // `name` from site.yml is a hint for the create-flow review page so
197
+ // the handle input is pre-filled. Ignored by authorize in other
198
+ // branches (fast path, intent=authorize).
199
+ name: typeof siteYml.name === 'string' ? siteYml.name : '',
200
+ callbackUrl: loopback.callbackUrl,
201
+ // Dev-only: admin-gated server-side. PHP rejects for non-admins.
202
+ skipBilling: skipBilling || undefined,
203
+ }
204
+ let authRes
205
+ try {
206
+ authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
207
+ } catch (err) {
208
+ // Stale-siteId recovery: the user's site.yml points at a site that
209
+ // no longer exists on the server (deleted, different env, etc.).
210
+ // Warn, drop the siteId, and retry — we'll land in the create flow
211
+ // and write a fresh site.id back to site.yml after success.
212
+ if (err.status === 404 && authorizeBody.siteId) {
213
+ say.warn(`site.id "${authorizeBody.siteId}" was not found on the server.`)
214
+ say.dim('Treating as a new site — the create flow will run in your browser.')
215
+ authorizeBody.siteId = ''
216
+ authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
217
+ } else {
218
+ say.err(`Authorize failed: ${err.message}`)
219
+ process.exit(1)
220
+ }
221
+ }
222
+
223
+ if (authRes.needsReview) {
224
+ const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
225
+ // openBrowser returns a hint about whether a GUI was available. On
226
+ // headless/CI environments (no DISPLAY, SSH session, no browser
227
+ // command), we print the URL + clear instructions instead of just
228
+ // "timed out" 15 minutes later.
229
+ say.info(`Opening browser for ${flowLabel}…`)
230
+ say.dim(authRes.reviewUrl)
231
+ const opened = await openBrowser(authRes.reviewUrl)
232
+ console.log('')
233
+ if (opened === false) {
234
+ say.warn('No browser could be launched in this environment.')
235
+ console.log(`${c.dim}Open this URL manually to complete the ${flowLabel}:${c.reset}`)
236
+ console.log(` ${authRes.reviewUrl}`)
237
+ console.log('')
238
+ console.log(`${c.dim}The browser must be able to POST to this CLI's loopback listener:${c.reset}`)
239
+ console.log(` ${loopback.callbackUrl}`)
240
+ console.log(`${c.dim}If you're in CI or over SSH, run this deploy from a machine with a browser.${c.reset}`)
241
+ console.log('')
242
+ }
243
+ console.log(`${c.dim}Awaiting authorization…${c.reset}`)
244
+ console.log(`${c.dim}(Will time out after ${REVIEW_TIMEOUT_MS / 60000} minutes)${c.reset}`)
245
+ console.log('')
246
+
247
+ const cb = await loopback.waitForCallback(REVIEW_TIMEOUT_MS)
248
+ if (!cb || !cb.publishToken) {
249
+ say.err('Browser authorization timed out or was denied.')
250
+ if (opened === false) {
251
+ say.dim('Hint: the browser may have run on a different machine and couldn\'t reach this CLI\'s loopback.')
252
+ }
253
+ process.exit(1)
254
+ }
255
+ publishToken = cb.publishToken
256
+ siteIdResolved = cb.siteId
257
+ handleResolved = cb.handle
258
+ // Review path: Worker URLs are implicit (we derive them from config).
259
+ publishUrl = `${workerUrl}/api/publish/process`
260
+ validateUrl = `${workerUrl}/api/publish/validate`
261
+ } else {
262
+ publishToken = authRes.publishToken
263
+ siteIdResolved = authRes.siteId
264
+ handleResolved = authRes.handle
265
+ publishUrl = authRes.publishUrl
266
+ validateUrl = authRes.validateUrl
267
+ }
268
+ } finally {
269
+ loopback.close()
270
+ }
271
+
272
+ // Pre-flight against the Worker. Surfaces "foundation not published" /
273
+ // "runtime not found" / namespace mismatch BEFORE we ship content.
274
+ say.info('Validating foundation + runtime…')
275
+ const validation = await callValidate({
276
+ url: validateUrl,
277
+ token: publishToken,
278
+ body: { foundation, runtimeVersion },
279
+ })
280
+ if (!validation.valid) {
281
+ say.err('Pre-flight validation failed:')
282
+ for (const issue of validation.issues || []) {
283
+ console.log(` ${c.red}${issue.code}${c.reset}: ${issue.message}`)
284
+ if (issue.fix) console.log(` ${c.dim}${issue.fix}${c.reset}`)
285
+ }
286
+ process.exit(1)
287
+ }
288
+
289
+ // Asset pipeline — upload dist/assets/* to S3, rewrite siteContent to use
290
+ // identifier-based references so semantic-parser resolves CDN URLs (+
291
+ // optimized variants) at render time. Skipped with --skip-assets.
292
+ // Mutates siteContent in place: image/document nodes get info.identifier.
293
+ if (!skipAssets) {
294
+ await uploadAssetsAndRewriteContent({
295
+ siteDir,
296
+ siteContent,
297
+ siteYml,
298
+ theme,
299
+ backendUrl,
300
+ cliToken,
301
+ siteId: siteIdResolved,
302
+ })
303
+ } else {
304
+ say.dim('Skipping asset upload (--skip-assets).')
305
+ }
306
+
307
+ say.info('Publishing…')
308
+ const publishPayload = {
309
+ foundation,
310
+ runtimeVersion,
311
+ theme,
312
+ languages,
313
+ defaultLanguage,
314
+ // Phase 1 single-locale wraps the content under the active locale. Multi-
315
+ // locale CLI deploy needs per-locale collection (deferred — see plan §6).
316
+ locales: { [defaultLanguage]: siteContent },
317
+ }
318
+ await callPublish({ url: publishUrl, token: publishToken, body: publishPayload })
319
+
320
+ // Write site.id / site.handle back to site.yml so next `uniweb deploy`
321
+ // fast-paths. Only touches the file on first deploy (or when the handle
322
+ // drifted server-side).
323
+ if (siteIdResolved && !siteYml.site?.id) {
324
+ await writeSiteBinding(siteYmlPath, siteYml, { id: siteIdResolved, handle: handleResolved })
325
+ say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
326
+ } else if (siteIdResolved && handleResolved && siteYml.site?.handle !== handleResolved) {
327
+ await writeSiteBinding(siteYmlPath, siteYml, { id: siteIdResolved, handle: handleResolved })
328
+ say.dim(`Updated site.yml handle → ${handleResolved}`)
329
+ }
330
+
331
+ console.log('')
332
+ say.ok(`Deployed ${c.bold}${handleResolved || siteIdResolved || 'site'}${c.reset}`)
333
+ if (handleResolved) {
334
+ console.log(` ${c.cyan}https://${handleResolved}.uniweb.website/${c.reset}`)
335
+ }
40
336
  }
41
337
 
42
- function info(message) {
43
- console.log(`${colors.cyan}→${colors.reset} ${message}`)
338
+ // ─── site.yml ──────────────────────────────────────────────
339
+
340
+ async function readSiteYml(path) {
341
+ if (!existsSync(path)) return {}
342
+ try {
343
+ const parsed = yaml.load(await readFile(path, 'utf8'))
344
+ return parsed && typeof parsed === 'object' ? parsed : {}
345
+ } catch (err) {
346
+ say.err(`Could not parse ${path}: ${err.message}`)
347
+ process.exit(1)
348
+ }
44
349
  }
45
350
 
46
351
  /**
47
- * Resolve the site directory to deploy.
352
+ * Write site.id + site.handle back to site.yml, preserving other fields.
48
353
  *
49
- * Priority:
50
- * 1. In a site directory use it
51
- * 2. At workspace root, one site use it
52
- * 3. At workspace root, multiple → prompt (or error if non-interactive)
53
- * 4. No site → educational error with alternatives
54
- *
55
- * @param {string[]} args
56
- * @returns {Promise<string>} Absolute path to the site directory
354
+ * Note: this is not a full YAML-preserving write — comments and exact
355
+ * formatting are NOT preserved. js-yaml's `dump` re-emits the document.
356
+ * Acceptable for now; the Phase 1 plan doesn't promise comment preservation.
57
357
  */
358
+ async function writeSiteBinding(path, current, binding) {
359
+ const next = {
360
+ ...current,
361
+ site: { ...(current.site || {}), id: binding.id, handle: binding.handle },
362
+ }
363
+ const dumped = yaml.dump(next, { lineWidth: 120, noRefs: true, quotingType: "'" })
364
+ await writeFile(path, dumped)
365
+ }
366
+
367
+ // ─── Resolve site dir + runtime ────────────────────────────
368
+
58
369
  async function resolveSiteDir(args) {
59
370
  const cwd = process.cwd()
60
371
  const prefix = getCliPrefix()
61
372
 
62
- // Check if current directory is a site
63
373
  const type = await classifyPackage(cwd)
64
- if (type === 'site') {
65
- return cwd
66
- }
374
+ if (type === 'site') return cwd
67
375
 
68
- // Check workspace
69
376
  const workspaceRoot = findWorkspaceRoot(cwd)
70
377
  if (workspaceRoot) {
71
378
  const sites = await findSites(workspaceRoot)
72
-
73
- if (sites.length === 1) {
74
- return resolve(workspaceRoot, sites[0])
75
- }
76
-
379
+ if (sites.length === 1) return resolve(workspaceRoot, sites[0])
77
380
  if (sites.length > 1) {
78
381
  if (isNonInteractive(args)) {
79
- error('Multiple sites found. Specify which one to deploy.')
382
+ say.err('Multiple sites found. Specify which one to deploy.')
80
383
  console.log('')
81
384
  for (const s of sites) {
82
- console.log(` ${colors.cyan}cd ${s} && ${prefix} deploy${colors.reset}`)
385
+ console.log(` ${c.cyan}cd ${s} && ${prefix} deploy${c.reset}`)
83
386
  }
84
387
  process.exit(1)
85
388
  }
86
-
87
389
  const choice = await promptSelect('Which site?', sites)
88
390
  if (!choice) {
89
391
  console.log('\nDeploy cancelled.')
@@ -93,179 +395,694 @@ async function resolveSiteDir(args) {
93
395
  }
94
396
  }
95
397
 
96
- // No site found educational error
97
- error('No site found in this workspace.')
98
- console.log('')
99
- console.log(` ${colors.dim}\`deploy\` uploads your built site to Uniweb hosting.${colors.reset}`)
100
- console.log('')
101
- console.log(` ${colors.dim}The site is a standard Vite build — you can also upload dist/${colors.reset}`)
102
- console.log(` ${colors.dim}to any static host.${colors.reset}`)
398
+ say.err('No site found in this workspace.')
399
+ say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
103
400
  process.exit(1)
104
401
  }
105
402
 
106
- /**
107
- * Parse --registry <url> from args.
108
- * @param {string[]} args
109
- * @returns {string|null}
110
- */
111
- function parseRegistryUrl(args) {
112
- const idx = args.indexOf('--registry')
113
- if (idx === -1 || !args[idx + 1]) return null
114
- return args[idx + 1]
403
+ async function fetchLatestRuntime(workerUrl) {
404
+ try {
405
+ const res = await fetch(`${workerUrl}/api/runtime/latest`)
406
+ if (!res.ok) return null
407
+ const body = await res.json()
408
+ return body.version || null
409
+ } catch {
410
+ return null
411
+ }
412
+ }
413
+
414
+ // ─── Content helpers ───────────────────────────────────────
415
+
416
+ function extractLanguages(siteContent) {
417
+ const langs = siteContent?.config?.languages
418
+ if (!Array.isArray(langs) || langs.length === 0) return ['en']
419
+ // Editor-shape `[{ value, label }]` vs plain `[string]`.
420
+ return langs.map((l) => (typeof l === 'string' ? l : l?.value)).filter(Boolean)
115
421
  }
116
422
 
117
423
  /**
118
- * Derive a siteId from the site's package.json or directory name.
119
- * @param {string} siteDir
120
- * @returns {Promise<string>}
424
+ * Resolve theme config.
425
+ *
426
+ * The build pipeline does not (today) emit a separate theme.json, so we read
427
+ * the developer-authored theme.yml from the site root. The Worker's
428
+ * `buildTheme()` tolerates an empty config — sites with no theme.yml still
429
+ * publish, they just get default tokens.
121
430
  */
122
- async function deriveSiteId(siteDir) {
123
- const pkgPath = join(siteDir, 'package.json')
124
- if (existsSync(pkgPath)) {
431
+ async function readTheme(siteDir, siteContent) {
432
+ const themePath = join(siteDir, 'theme.yml')
433
+ if (existsSync(themePath)) {
125
434
  try {
126
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
127
- if (pkg.name) return pkg.name
435
+ const parsed = yaml.load(await readFile(themePath, 'utf8'))
436
+ if (parsed && typeof parsed === 'object') return parsed
128
437
  } catch {
129
- // Fall through
438
+ // fall through to site-content.json fallback
130
439
  }
131
440
  }
132
- return basename(siteDir)
441
+ // site-content sometimes carries a `theme` key produced by collectors.
442
+ if (siteContent?.theme && typeof siteContent.theme === 'object') {
443
+ return siteContent.theme
444
+ }
445
+ return {}
133
446
  }
134
447
 
135
- /**
136
- * Walk a directory recursively and collect all files as base64.
137
- * @param {string} dir
138
- * @returns {Promise<Object<string, string>>} Map of relative paths to base64 content
139
- */
140
- async function collectFiles(dir) {
141
- const files = {}
142
- const entries = await readdir(dir, { withFileTypes: true, recursive: true })
448
+ // ─── HTTP calls ────────────────────────────────────────────
143
449
 
144
- for (const entry of entries) {
145
- if (!entry.isFile()) continue
146
- const fullPath = join(entry.parentPath || entry.path, entry.name)
147
- const relPath = relative(dir, fullPath)
148
- const content = await readFile(fullPath)
149
- files[relPath] = content.toString('base64')
450
+ async function callAuthorize({ backendUrl, cliToken, body }) {
451
+ // PHP's BaseController reads the `action` from the JSON body (not the query
452
+ // string) when Content-Type: application/json. Every PHP POST needs to embed
453
+ // `action` in the payload.
454
+ const url = `${backendUrl}/cli-deploy.php`
455
+ const res = await fetch(url, {
456
+ method: 'POST',
457
+ headers: {
458
+ 'Content-Type': 'application/json',
459
+ Authorization: `Bearer ${cliToken}`,
460
+ },
461
+ body: JSON.stringify({ action: 'authorize', ...body }),
462
+ })
463
+
464
+ let parsed
465
+ try {
466
+ parsed = await res.json()
467
+ } catch {
468
+ say.err(`Authorize returned non-JSON (HTTP ${res.status})`)
469
+ process.exit(1)
470
+ }
471
+
472
+ if (!res.ok) {
473
+ // Throw a structured error so the caller can branch — 404 on a known
474
+ // siteId means "site.yml is stale, fall back to create flow" rather
475
+ // than "hard fail". Other statuses remain fatal to the caller.
476
+ const err = new Error(parsed?.error || `HTTP ${res.status}`)
477
+ err.status = res.status
478
+ throw err
150
479
  }
151
480
 
152
- return files
481
+ // The controller returns `data` wrapped by BaseController — unwrap if so.
482
+ return parsed.data ?? parsed
483
+ }
484
+
485
+ async function callValidate({ url, token, body }) {
486
+ const res = await fetch(url, {
487
+ method: 'POST',
488
+ headers: {
489
+ 'Content-Type': 'application/json',
490
+ Authorization: `Bearer ${token}`,
491
+ },
492
+ body: JSON.stringify(body),
493
+ })
494
+ if (!res.ok) {
495
+ let err = `HTTP ${res.status}`
496
+ try {
497
+ const j = await res.json()
498
+ err = j.error || err
499
+ } catch {}
500
+ say.err(`Validate failed: ${err}`)
501
+ process.exit(1)
502
+ }
503
+ return res.json()
504
+ }
505
+
506
+ async function callPublish({ url, token, body }) {
507
+ const res = await fetch(url, {
508
+ method: 'POST',
509
+ headers: {
510
+ 'Content-Type': 'application/json',
511
+ Authorization: `Bearer ${token}`,
512
+ },
513
+ body: JSON.stringify(body),
514
+ })
515
+ if (!res.ok) {
516
+ let err = `HTTP ${res.status}`
517
+ try {
518
+ const j = await res.json()
519
+ err = j.error || err
520
+ } catch {}
521
+ say.err(`Publish failed: ${err}`)
522
+ process.exit(1)
523
+ }
524
+ return res.json()
153
525
  }
154
526
 
527
+ // ─── Asset pipeline (Phase 4) ──────────────────────────────
528
+
155
529
  /**
156
- * Main deploy command handler
530
+ * Walk dist/assets/*, diff against the server's manifest, upload what
531
+ * changed, and rewrite siteContent's image/document nodes to reference
532
+ * identifiers. Designed to be idempotent: on a no-change deploy, the diff
533
+ * yields zero uploads and only the rewrite runs (cheap).
534
+ *
535
+ * siteContent is mutated in place so the caller's publish payload picks up
536
+ * the rewritten nodes without passing anything back.
157
537
  */
158
- export async function deploy(args = []) {
159
- const isLocal = args.includes('--local')
160
- const isDryRun = args.includes('--dry-run')
161
- const registryUrl = parseRegistryUrl(args)
162
- const prefix = getCliPrefix()
538
+ async function uploadAssetsAndRewriteContent({ siteDir, siteContent, siteYml, theme, backendUrl, cliToken, siteId }) {
539
+ const distAssetsDir = join(siteDir, 'dist', 'assets')
540
+ const hasDistAssets = existsSync(distAssetsDir)
163
541
 
164
- // 1. Resolve site directory
165
- const siteDir = await resolveSiteDir(args)
542
+ // 1. Enumerate local files + read size.
543
+ const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
166
544
 
167
- // 2. Check auth (unless --local)
168
- let token = null
169
- if (!isLocal) {
170
- token = await ensureAuth({ command: 'Deploying' })
545
+ // 1a. Favicon sits at site root, not in dist/assets. Ship it through
546
+ // the same pipeline so it ends up at assets.uniweb.app with an
547
+ // identifier; config.favicon gets set further down.
548
+ const faviconPath = await detectFavicon(siteDir, siteYml)
549
+ if (faviconPath) {
550
+ const ext = (faviconPath.split('.').pop() || '').toLowerCase()
551
+ const st = await stat(faviconPath)
552
+ localFiles.push({
553
+ filename: faviconPath.split(sep).pop(),
554
+ fullPath: faviconPath,
555
+ size: st.size,
556
+ mime: MIME_BY_EXT[ext] || 'application/octet-stream',
557
+ })
171
558
  }
172
559
 
173
- // 3. Auto-build if dist/ is missing
174
- const distDir = join(siteDir, 'dist')
175
- const indexHtml = join(distDir, 'index.html')
560
+ // 1b. Custom fonts scan public/fonts/<family>/<weight>-<style>.{woff,woff2}
561
+ // filtered to families actually referenced by theme slots. Each file
562
+ // enters the same upload pipeline; faces[] with CDN URLs is assembled
563
+ // below after identifiers are known.
564
+ const fontFiles = theme?.fonts?.faces
565
+ ? [] // User declared faces manually — skip auto-scan
566
+ : await discoverUsedFonts(siteDir, theme)
567
+ for (const f of fontFiles) {
568
+ localFiles.push({
569
+ filename: f.filename,
570
+ fullPath: f.fullPath,
571
+ size: f.size,
572
+ mime: MIME_BY_EXT[(f.filename.split('.').pop() || '').toLowerCase()] || 'application/octet-stream',
573
+ })
574
+ }
176
575
 
177
- if (!existsSync(indexHtml)) {
178
- console.log(`${colors.yellow}⚠${colors.reset} No build found. Building site...`)
179
- console.log('')
180
- execSync('npx uniweb build', {
181
- cwd: siteDir,
182
- stdio: 'inherit',
576
+ if (localFiles.length === 0) {
577
+ say.dim('No assets to upload.')
578
+ return
579
+ }
580
+
581
+ // 2. Fetch server manifest.
582
+ const server = await callAssetsAction({ backendUrl, cliToken, action: 'listAssets', body: { siteId } })
583
+ const byFilename = new Map()
584
+ for (const a of server.assets || []) byFilename.set(a.filename, a)
585
+
586
+ // 3. Diff. Vite-hashed filenames are content-addressed (filename match →
587
+ // skip); unhashed formats fall through to size compare.
588
+ const needUpload = []
589
+ const reused = new Map() // filename → identifier (for content rewrite)
590
+ for (const f of localFiles) {
591
+ const server = byFilename.get(f.filename)
592
+ if (!server) {
593
+ needUpload.push(f)
594
+ continue
595
+ }
596
+ if (VITE_HASHED_FILENAME_RE.test(f.filename) || server.size === f.size) {
597
+ reused.set(f.filename, server.identifier)
598
+ } else {
599
+ needUpload.push(f)
600
+ }
601
+ }
602
+
603
+ say.info(
604
+ `Assets: ${c.bold}${needUpload.length}${c.reset} to upload, ` +
605
+ `${c.bold}${reused.size}${c.reset} reused, ` +
606
+ `${c.bold}${server.assets?.length || 0}${c.reset} on server.`
607
+ )
608
+
609
+ // 4. Plan + upload new ones.
610
+ const fresh = new Map() // filename → identifier
611
+ if (needUpload.length > 0) {
612
+ const plan = await callAssetsAction({
613
+ backendUrl, cliToken, action: 'planUploads',
614
+ body: {
615
+ siteId,
616
+ files: needUpload.map((f) => ({ filename: f.filename, size: f.size, mime: f.mime })),
617
+ },
183
618
  })
184
- console.log('')
185
619
 
186
- if (!existsSync(indexHtml)) {
187
- error('Build did not produce dist/index.html')
620
+ if (plan.quota) {
621
+ const usedMB = (plan.quota.usedBytes / 1048576).toFixed(1)
622
+ const addKB = (plan.quota.wouldAddBytes / 1024).toFixed(1)
623
+ say.dim(`Storage: ${usedMB} MB used (+${addKB} KB this deploy)`)
624
+ }
625
+
626
+ const byFilenameInPlan = new Map()
627
+ for (const u of plan.uploads || []) byFilenameInPlan.set(u.filename, u)
628
+
629
+ // Parallel upload with bounded concurrency + per-file retries.
630
+ const queue = needUpload.map((f) => ({ f, plan: byFilenameInPlan.get(f.filename) }))
631
+ const confirmed = []
632
+ const failed = []
633
+ await runInPool(queue, ASSET_UPLOAD_CONCURRENCY, async ({ f, plan }) => {
634
+ if (!plan) {
635
+ failed.push(f.filename)
636
+ return
637
+ }
638
+ const ok = await putToS3WithRetry(f, plan.presignedPost, ASSET_UPLOAD_RETRIES)
639
+ if (ok) {
640
+ confirmed.push({ recordId: plan.recordId, filename: f.filename, identifier: plan.identifier })
641
+ } else {
642
+ failed.push(f.filename)
643
+ }
644
+ })
645
+
646
+ if (failed.length > 0) {
647
+ say.err(`Asset upload failed for ${failed.length} file(s): ${failed.join(', ')}`)
188
648
  process.exit(1)
189
649
  }
650
+
651
+ // 5. Commit successful uploads.
652
+ const confirmRes = await callAssetsAction({
653
+ backendUrl, cliToken, action: 'confirmUploads',
654
+ body: { siteId, uploaded: confirmed.map((u) => ({ recordId: u.recordId })) },
655
+ })
656
+ if ((confirmRes.failed || []).length > 0) {
657
+ say.warn(`Server couldn't confirm ${confirmRes.failed.length} upload(s). Check storage/retry.`)
658
+ }
659
+ for (const u of confirmed) fresh.set(u.filename, u.identifier)
190
660
  }
191
661
 
192
- // 4. Derive siteId
193
- const siteId = await deriveSiteId(siteDir)
662
+ // 6. Rewrite siteContent in place. Each image/document node whose
663
+ // src/href references a local /assets/{filename} gets an info.identifier
664
+ // pointing to the uploaded (or reused) asset.
665
+ const byFilenameAll = new Map([...reused, ...fresh])
666
+ const rewritten = rewriteAssetReferences(siteContent, byFilenameAll)
667
+ if (rewritten > 0) {
668
+ say.dim(`Rewrote ${rewritten} asset reference(s) in site content.`)
669
+ }
194
670
 
195
- // 5. Collect files from dist/
196
- const files = await collectFiles(distDir)
197
- const filesCount = Object.keys(files).length
671
+ // 7. If a favicon was included above, inject its resolved CDN URL into
672
+ // siteContent.config.favicon. Matches how Editor publish composes the
673
+ // payload; Worker bakes <link rel="icon"> from this field.
674
+ if (faviconPath) {
675
+ const favName = faviconPath.split(sep).pop()
676
+ const favIdentifier = byFilenameAll.get(favName)
677
+ if (favIdentifier) {
678
+ const faviconUrl = resolveAssetCdnUrl(favIdentifier)
679
+ siteContent.config = { ...(siteContent.config || {}), favicon: faviconUrl }
680
+ say.dim(`Favicon: ${favName}`)
681
+ }
682
+ }
198
683
 
199
- // 6. Dry-run check
200
- if (isDryRun) {
201
- console.log('')
202
- info(`Would deploy ${colors.bright}${siteId}${colors.reset} (${filesCount} files)`)
203
- console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
204
- const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
205
- console.log(` ${colors.dim}Target: ${serverUrl}/sites/${siteId}/${colors.reset}`)
206
- return
684
+ // 8. Assemble theme.fonts.faces from uploaded font files. Replaces the
685
+ // local /fonts/... src with the CDN URL for each identifier. Mirrors
686
+ // unicloud's scanFontDirectory → faces[] shape so @uniweb/theming
687
+ // emits @font-face + preload links without any other changes.
688
+ if (fontFiles.length > 0) {
689
+ const faces = []
690
+ for (const f of fontFiles) {
691
+ const identifier = byFilenameAll.get(f.filename)
692
+ if (!identifier) continue
693
+ faces.push({
694
+ family: f.family,
695
+ src: resolveAssetCdnUrl(identifier),
696
+ weight: f.weight,
697
+ style: f.style,
698
+ format: f.format,
699
+ })
700
+ }
701
+ if (faces.length > 0) {
702
+ theme.fonts = { ...(theme.fonts || {}), faces }
703
+ const families = [...new Set(faces.map((x) => x.family))].join(', ')
704
+ say.dim(`Fonts: ${faces.length} face(s) across ${families}`)
705
+ }
706
+ }
707
+ }
708
+
709
+ async function walkAssetDir(dir) {
710
+ const out = []
711
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
712
+ for (const entry of entries) {
713
+ if (!entry.isFile()) continue
714
+ const ext = (entry.name.split('.').pop() || '').toLowerCase()
715
+ // Only upload media. JS/CSS/JSON/map files in dist/assets/ are Vite's
716
+ // build output — the Worker serves the site via runtime/{version}/ +
717
+ // content injection, not from these chunks.
718
+ if (!MEDIA_EXTENSIONS.has(ext)) continue
719
+ const fullPath = join(entry.parentPath || entry.path, entry.name)
720
+ const st = await stat(fullPath)
721
+ out.push({
722
+ filename: entry.name,
723
+ fullPath,
724
+ size: st.size,
725
+ mime: MIME_BY_EXT[ext] || 'application/octet-stream',
726
+ })
207
727
  }
728
+ return out
729
+ }
208
730
 
209
- // 7. Deploy
210
- const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
211
- info(`Deploying ${colors.bright}${siteId}${colors.reset} (${filesCount} files)...`)
731
+ // Detect the site's favicon on disk. Order: explicit `favicon:` in site.yml,
732
+ // then any of favicon.{svg,ico,png,webp} at the site root. Returns null when
733
+ // nothing is found (site serves without a favicon).
734
+ async function detectFavicon(siteDir, siteYml) {
735
+ if (typeof siteYml?.favicon === 'string' && siteYml.favicon.trim()) {
736
+ const p = resolve(siteDir, siteYml.favicon.trim())
737
+ if (existsSync(p)) return p
738
+ say.warn(`site.yml favicon "${siteYml.favicon}" not found on disk — falling back to auto-detect.`)
739
+ }
740
+ // Check both the site root and Vite's public/ directory (public/* is the
741
+ // source for static assets copied verbatim into dist/ at build time).
742
+ const dirs = [siteDir, join(siteDir, 'public')]
743
+ for (const dir of dirs) {
744
+ for (const name of ['favicon.svg', 'favicon.ico', 'favicon.png', 'favicon.webp']) {
745
+ const p = join(dir, name)
746
+ if (existsSync(p)) return p
747
+ }
748
+ }
749
+ return null
750
+ }
212
751
 
213
- const headers = { 'Content-Type': 'application/json' }
214
- if (token) {
215
- headers['Authorization'] = `Bearer ${token}`
752
+ // Named weight CSS numeric weight. Matches unicloud's font-scanner.js so
753
+ // the CLI-deploy path and the local unicloud dev path agree on conventions.
754
+ const FONT_WEIGHT_MAP = {
755
+ thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300,
756
+ normal: 400, regular: 400, medium: 500, semibold: 600, demibold: 600,
757
+ bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900,
758
+ }
759
+
760
+ // Parse "bold-normal.woff2" / "400-italic.woff" style filenames into weight,
761
+ // style, format. Returns null on any unrecognized shape (caller skips the file).
762
+ function parseFontFilename(filename) {
763
+ const dotIdx = filename.lastIndexOf('.')
764
+ if (dotIdx === -1) return null
765
+ const ext = filename.slice(dotIdx + 1).toLowerCase()
766
+ if (ext !== 'woff' && ext !== 'woff2') return null
767
+ const format = ext === 'woff2' ? 'woff2' : 'woff'
768
+ const stem = filename.slice(0, dotIdx)
769
+ const parts = stem.split('-')
770
+ if (parts.length < 2) return null
771
+ const style = parts[parts.length - 1].toLowerCase()
772
+ if (style !== 'normal' && style !== 'italic') return null
773
+ const weightPart = parts.slice(0, -1).join('').toLowerCase()
774
+ const numWeight = parseInt(weightPart, 10)
775
+ if (!isNaN(numWeight) && numWeight >= 1 && numWeight <= 999) {
776
+ return { weight: numWeight, style, format }
216
777
  }
778
+ const mapped = FONT_WEIGHT_MAP[weightPart]
779
+ if (mapped) return { weight: mapped, style, format }
780
+ return null
781
+ }
217
782
 
218
- const payload = {
219
- siteId,
220
- files,
221
- metadata: {
222
- deployedBy: isLocal ? 'local' : 'cli',
223
- },
783
+ // Extract the set of lowercase family names referenced by theme slots
784
+ // (heading/body/mono and any declared _userSlots). Mirrors
785
+ // @uniweb/theming's extractUsedFamilies — used here to drop font files
786
+ // for families the theme doesn't actually consume, so upload stays lean.
787
+ function extractUsedFontFamilies(theme) {
788
+ const fonts = theme?.fonts || {}
789
+ const slots = fonts._userSlots || ['body', 'heading', 'mono']
790
+ const generic = new Set([
791
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
792
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
793
+ ])
794
+ const used = new Set()
795
+ for (const slot of slots) {
796
+ const v = fonts[slot]
797
+ if (typeof v !== 'string') continue
798
+ for (const seg of v.split(',')) {
799
+ const n = seg.trim().replace(/^["']|["']$/g, '').toLowerCase()
800
+ if (n && !generic.has(n)) used.add(n)
801
+ }
224
802
  }
803
+ return used
804
+ }
805
+
806
+ // Scan public/fonts/<family>/<weight>-<style>.{woff,woff2} and return the
807
+ // files belonging to families that the theme actually uses. Returning [] is
808
+ // the normal case for sites that don't ship custom fonts.
809
+ async function discoverUsedFonts(siteDir, theme) {
810
+ const fontsDir = join(siteDir, 'public', 'fonts')
811
+ if (!existsSync(fontsDir)) return []
812
+ const used = extractUsedFontFamilies(theme)
813
+ if (used.size === 0) return []
225
814
 
226
- let res
815
+ let familyDirs
227
816
  try {
228
- res = await fetch(`${serverUrl}/deploy`, {
229
- method: 'POST',
230
- headers,
231
- body: JSON.stringify(payload),
232
- })
233
- } catch (err) {
234
- error(`Could not connect to ${serverUrl}`)
235
- console.log('')
236
- console.log(` ${colors.dim}Make sure the cloud server is running:${colors.reset}`)
237
- console.log(` ${colors.cyan}cd packages/cloud && pnpm dev${colors.reset}`)
238
- process.exit(1)
817
+ familyDirs = await readdir(fontsDir, { withFileTypes: true })
818
+ } catch {
819
+ return []
239
820
  }
240
821
 
241
- const body = await res.json()
822
+ const out = []
823
+ for (const entry of familyDirs) {
824
+ if (!entry.isDirectory()) continue
825
+ const family = entry.name.toLowerCase()
826
+ if (!used.has(family)) continue // Skip unreferenced families.
827
+ const familyDir = join(fontsDir, entry.name)
828
+ let files
829
+ try {
830
+ files = await readdir(familyDir, { withFileTypes: true })
831
+ } catch { continue }
832
+ for (const file of files) {
833
+ if (!file.isFile()) continue
834
+ const parsed = parseFontFilename(file.name)
835
+ if (!parsed) continue
836
+ const fullPath = join(familyDir, file.name)
837
+ const st = await stat(fullPath)
838
+ out.push({
839
+ filename: file.name,
840
+ fullPath,
841
+ size: st.size,
842
+ family,
843
+ weight: parsed.weight,
844
+ style: parsed.style,
845
+ format: parsed.format,
846
+ })
847
+ }
848
+ }
849
+ return out
850
+ }
242
851
 
852
+ // Resolve an asset identifier ({uuid}/{filename}) to the canonical CDN URL.
853
+ // Mirrors `resolveAssetIdentifier` in @uniweb/semantic-parser so the favicon
854
+ // URL shape matches everything else the Worker sees from Editor publishes.
855
+ function resolveAssetCdnUrl(identifier) {
856
+ if (!identifier || typeof identifier !== 'string') return ''
857
+ const [uuid, filename] = identifier.split('/')
858
+ if (!filename) return ''
859
+ const ext = filename.substring(filename.lastIndexOf('.') + 1)
860
+ return `https://assets.uniweb.app/dist/${uuid}/base.${ext}`
861
+ }
862
+
863
+ async function callAssetsAction({ backendUrl, cliToken, action, body }) {
864
+ const res = await fetch(`${backendUrl}/cli-assets.php`, {
865
+ method: 'POST',
866
+ headers: {
867
+ 'Content-Type': 'application/json',
868
+ Authorization: `Bearer ${cliToken}`,
869
+ },
870
+ body: JSON.stringify({ action, ...body }),
871
+ })
872
+ let parsed
873
+ try { parsed = await res.json() } catch {
874
+ throw new Error(`cli-assets.${action} returned non-JSON (HTTP ${res.status})`)
875
+ }
243
876
  if (!res.ok) {
244
- if (res.status === 401) {
245
- error('Authentication failed.')
246
- console.log(` Run ${colors.cyan}${prefix} login${colors.reset} to refresh your credentials.`)
247
- process.exit(1)
248
- }
249
- error(body.error || `Deploy failed (${res.status})`)
250
- process.exit(1)
877
+ throw new Error(parsed?.error || `cli-assets.${action} failed (HTTP ${res.status})`)
251
878
  }
879
+ return parsed.data ?? parsed
880
+ }
252
881
 
253
- console.log('')
254
- success(`Deployed ${colors.bright}${siteId}${colors.reset}`)
882
+ /**
883
+ * POST a single file to S3 via a pre-signed POST. Retries transient
884
+ * failures (network errors + 5xx) up to `maxRetries` times before giving up.
885
+ * S3 pre-signed POSTs don't support resumable upload, so each retry is a
886
+ * full re-POST. File sizes are <= 50 MB so that's tolerable.
887
+ */
888
+ async function putToS3WithRetry(file, presigned, maxRetries) {
889
+ const body = await readFile(file.fullPath)
890
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
891
+ try {
892
+ // Node's FormData doesn't produce what S3 wants — build a multipart
893
+ // body manually using fetch's standard FormData, giving us File-like
894
+ // semantics via Blob.
895
+ const form = new FormData()
896
+ for (const [k, v] of Object.entries(presigned.fields)) form.append(k, String(v))
897
+ form.append('file', new Blob([body], { type: file.mime }), file.filename)
255
898
 
256
- const siteUrl = body.siteUrl
257
- ? `${serverUrl}${body.siteUrl}`
258
- : `${serverUrl}/sites/${siteId}/`
259
- console.log(` ${colors.cyan}${siteUrl}${colors.reset}`)
899
+ const res = await fetch(presigned.url, { method: 'POST', body: form })
900
+ if (res.ok || res.status === 204) return true
901
+ if (res.status >= 500 && attempt < maxRetries) continue
902
+ return false
903
+ } catch {
904
+ if (attempt < maxRetries) continue
905
+ return false
906
+ }
907
+ }
908
+ return false
909
+ }
260
910
 
261
- // Cross-promotion: if workspace has a foundation, tip about publish
262
- const workspaceRoot = findWorkspaceRoot(siteDir)
263
- if (workspaceRoot) {
264
- const foundations = await findFoundations(workspaceRoot)
265
- if (foundations.length > 0) {
266
- console.log('')
267
- console.log(` ${colors.dim}Tip: Run \`${prefix} publish\` to register your foundation and invite clients.${colors.reset}`)
911
+ /**
912
+ * Run up to `concurrency` promises at a time from `items`. Returns when all
913
+ * settle. Propagates errors as thrown (caller wraps in try/catch if needed)
914
+ * but the worker here swallows per-item errors and collects them instead.
915
+ */
916
+ async function runInPool(items, concurrency, worker) {
917
+ let i = 0
918
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
919
+ while (i < items.length) {
920
+ const idx = i++
921
+ await worker(items[idx])
922
+ }
923
+ })
924
+ await Promise.all(runners)
925
+ }
926
+
927
+ /**
928
+ * Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
929
+ * `attrs.src` or `attrs.href` references a local `/assets/{filename}` that
930
+ * we've uploaded/reused. Sets `attrs.info.identifier` so semantic-parser
931
+ * resolves the real CDN URL (and optimized variants) at render time.
932
+ *
933
+ * Returns the number of rewrites performed — useful for reporting, and to
934
+ * detect "nothing matched" (likely a content-shape mismatch worth flagging).
935
+ */
936
+ function rewriteAssetReferences(node, byFilename) {
937
+ let count = 0
938
+ const walk = (n) => {
939
+ if (!n || typeof n !== 'object') return
940
+ if (Array.isArray(n)) { for (const child of n) walk(child); return }
941
+ if (n.attrs && typeof n.attrs === 'object') {
942
+ const srcRef = pickAssetRef(n.attrs.src)
943
+ const hrefRef = pickAssetRef(n.attrs.href)
944
+ const ref = srcRef || hrefRef
945
+ if (ref) {
946
+ const identifier = byFilename.get(ref)
947
+ if (identifier) {
948
+ n.attrs.info = {
949
+ ...(n.attrs.info || {}),
950
+ identifier,
951
+ contentType: 'website',
952
+ viewType: 'profile',
953
+ }
954
+ // Clear the local Vite-hashed path so the runtime resolves via
955
+ // info.identifier (→ assets.uniweb.app CDN) instead of requesting
956
+ // a non-existent /assets/... file from the site host.
957
+ if (srcRef) n.attrs.src = null
958
+ if (hrefRef) n.attrs.href = null
959
+ // Match the Editor shape: plain `image` nodes skip identifier
960
+ // resolution in older runtimes; `ImageBlock` routes through
961
+ // parseImgBlock which reads info.identifier and fills url.
962
+ if (n.type === 'image' && n.attrs.role !== 'icon') {
963
+ n.type = 'ImageBlock'
964
+ }
965
+ count++
966
+ }
967
+ }
268
968
  }
969
+ for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
970
+ }
971
+ walk(node)
972
+ return count
973
+ }
974
+
975
+ function pickAssetRef(v) {
976
+ if (typeof v !== 'string') return null
977
+ // Match "/assets/filename.ext", "./assets/filename.ext", "assets/filename.ext".
978
+ const m = v.match(/(?:^|\/|\.\/)assets\/([^/?#]+)$/)
979
+ return m ? m[1] : null
980
+ }
981
+
982
+ // ─── Loopback listener (review path) ───────────────────────
983
+
984
+ /**
985
+ * Start an HTTP server on a random loopback port to receive the publish
986
+ * token from the browser. The server accepts ONE request to /callback; after
987
+ * that it's closed.
988
+ *
989
+ * Same shape as `login.js::browserLogin`, but POST-accepting since the web
990
+ * app POSTs JSON (not a redirect with query params like CliAuthController).
991
+ */
992
+ async function startLoopback() {
993
+ return new Promise((resolveReady) => {
994
+ let resolveCallback
995
+ const callbackPromise = new Promise((r) => { resolveCallback = r })
996
+
997
+ const server = createServer((req, res) => {
998
+ const u = new URL(req.url, 'http://localhost')
999
+ if (u.pathname !== '/callback') {
1000
+ res.writeHead(404)
1001
+ res.end('Not found')
1002
+ return
1003
+ }
1004
+
1005
+ // CORS preflight — the web app POSTs JSON cross-origin, so browsers
1006
+ // send an OPTIONS preflight first. Respond with permissive CORS headers.
1007
+ if (req.method === 'OPTIONS') {
1008
+ res.writeHead(204, {
1009
+ 'Access-Control-Allow-Origin': '*',
1010
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
1011
+ 'Access-Control-Allow-Headers': 'Content-Type',
1012
+ 'Access-Control-Max-Age': '3600',
1013
+ })
1014
+ res.end()
1015
+ return
1016
+ }
1017
+
1018
+ // Accept POST (web app posts JSON) or GET (browser redirect with params)
1019
+ if (req.method === 'POST') {
1020
+ let buf = ''
1021
+ req.on('data', (chunk) => (buf += chunk))
1022
+ req.on('end', () => {
1023
+ let payload = {}
1024
+ try { payload = JSON.parse(buf) } catch {}
1025
+ respondSuccess(res)
1026
+ resolveCallback(payload)
1027
+ })
1028
+ return
1029
+ }
1030
+ if (req.method === 'GET') {
1031
+ const publishToken = u.searchParams.get('token')
1032
+ const siteId = u.searchParams.get('siteId')
1033
+ const handle = u.searchParams.get('handle')
1034
+ if (!publishToken) {
1035
+ res.writeHead(400, { 'Content-Type': 'text/html' })
1036
+ res.end('<h2>Missing token</h2>')
1037
+ return
1038
+ }
1039
+ respondSuccess(res)
1040
+ resolveCallback({ publishToken, siteId, handle })
1041
+ return
1042
+ }
1043
+ res.writeHead(405)
1044
+ res.end('Method not allowed')
1045
+ })
1046
+
1047
+ server.listen(0, '127.0.0.1', () => {
1048
+ const port = server.address().port
1049
+ resolveReady({
1050
+ callbackUrl: `http://127.0.0.1:${port}/callback`,
1051
+ waitForCallback: (timeoutMs) => Promise.race([
1052
+ callbackPromise,
1053
+ new Promise((r) => setTimeout(() => r(null), timeoutMs)),
1054
+ ]),
1055
+ close: () => { try { server.close() } catch {} },
1056
+ })
1057
+ })
1058
+ })
1059
+ }
1060
+
1061
+ function respondSuccess(res) {
1062
+ // CORS preflight + actual response, since the web app POSTs cross-origin.
1063
+ res.writeHead(200, {
1064
+ 'Content-Type': 'text/html; charset=utf-8',
1065
+ 'Access-Control-Allow-Origin': '*',
1066
+ })
1067
+ res.end(
1068
+ '<html><body style="font-family:system-ui;text-align:center;padding:60px">' +
1069
+ '<h2 style="color:#16a34a">Deploy authorized</h2>' +
1070
+ '<p>You can close this window and return to your terminal.</p>' +
1071
+ '</body></html>'
1072
+ )
1073
+ }
1074
+
1075
+ async function openBrowser(url) {
1076
+ try {
1077
+ const { exec } = await import('node:child_process')
1078
+ const cmd = process.platform === 'darwin'
1079
+ ? `open "${url}"`
1080
+ : process.platform === 'win32'
1081
+ ? `start "" "${url}"`
1082
+ : `xdg-open "${url}"`
1083
+ return new Promise((r) => exec(cmd, (err) => r(!err)))
1084
+ } catch {
1085
+ return false
269
1086
  }
270
1087
  }
271
1088