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