uniweb 0.10.2 → 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.
- package/README.md +3 -3
- package/package.json +7 -7
- package/partials/agents.md +26 -1
- package/src/commands/build.js +44 -3
- package/src/commands/deploy.js +981 -164
- package/src/commands/handoff.js +1 -1
- package/src/commands/invite.js +2 -2
- package/src/framework-index.json +22 -11
- package/src/index.js +3 -2
- package/src/utils/config.js +1 -1
- package/templates/foundation/package.json.hbs +2 -2
- package/templates/site/package.json.hbs +2 -2
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
|
8
|
-
* uniweb deploy --
|
|
9
|
-
* uniweb deploy --
|
|
10
|
-
* uniweb deploy --
|
|
11
|
-
*
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
console.log(`${
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
352
|
+
* Write site.id + site.handle back to site.yml, preserving other fields.
|
|
48
353
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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(` ${
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
123
|
-
const
|
|
124
|
-
if (existsSync(
|
|
431
|
+
async function readTheme(siteDir, siteContent) {
|
|
432
|
+
const themePath = join(siteDir, 'theme.yml')
|
|
433
|
+
if (existsSync(themePath)) {
|
|
125
434
|
try {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
435
|
+
const parsed = yaml.load(await readFile(themePath, 'utf8'))
|
|
436
|
+
if (parsed && typeof parsed === 'object') return parsed
|
|
128
437
|
} catch {
|
|
129
|
-
//
|
|
438
|
+
// fall through to site-content.json fallback
|
|
130
439
|
}
|
|
131
440
|
}
|
|
132
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
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.
|
|
165
|
-
const
|
|
542
|
+
// 1. Enumerate local files + read size.
|
|
543
|
+
const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
|
|
166
544
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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 (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 (
|
|
187
|
-
|
|
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
|
-
//
|
|
193
|
-
|
|
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
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
815
|
+
let familyDirs
|
|
227
816
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|