uniweb 0.12.7 → 0.12.9
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 +4 -4
- package/partials/agents.md +6 -1
- package/src/commands/build.js +51 -30
- package/src/commands/deploy.js +695 -415
- package/src/commands/export.js +93 -0
- package/src/commands/publish.js +288 -116
- package/src/framework-index.json +3 -3
- package/src/index.js +26 -8
- package/src/utils/config.js +8 -2
- package/src/utils/env.js +22 -0
- package/src/utils/registry.js +4 -5
- package/templates/foundation/package.json.hbs +1 -1
- package/src/utils/receipt.js +0 -91
package/src/commands/deploy.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deploy Command
|
|
3
3
|
*
|
|
4
|
-
* Deploys a
|
|
5
|
-
*
|
|
4
|
+
* Deploys a site. Host is determined by `deploy.host` in site.yml (or
|
|
5
|
+
* `--host <name>` flag). The default is `uniweb`:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* - `uniweb` (default): Uniweb hosting — link-mode + edge JIT prerender.
|
|
8
|
+
* Foundation loaded by URL from the registry. Requires `uniweb login`
|
|
9
|
+
* and a `foundation:` declaration in site.yml.
|
|
10
|
+
*
|
|
11
|
+
* - Static-host adapters (`s3-cloudfront`, `cloudflare-pages`,
|
|
12
|
+
* `github-pages`, `generic-static`, …): build dist/ in bundle-mode
|
|
13
|
+
* and hand it to a host adapter for upload + invalidation. No login,
|
|
14
|
+
* no edge. See kb/framework/plans/static-host-deploy-adapters.md.
|
|
15
|
+
*
|
|
16
|
+
* For static-host artifacts WITHOUT upload, see `uniweb export`.
|
|
17
|
+
*
|
|
18
|
+
* Default-flow steps:
|
|
8
19
|
* 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
|
|
9
|
-
* 2. Resolve runtime (default: GET /
|
|
20
|
+
* 2. Resolve runtime (default: GET /runtime/latest from the Worker).
|
|
10
21
|
* 3. ensureAuth() → bearer CLI JWT from ~/.uniweb/auth.json.
|
|
11
22
|
* 4. Build `dist/` if missing.
|
|
12
23
|
* 5. Load dist/site-content.json → extract `languages` for the capability
|
|
@@ -18,19 +29,25 @@
|
|
|
18
29
|
* - publishToken returned → fast path.
|
|
19
30
|
* - needsReview:true + reviewUrl → open browser, wait for callback,
|
|
20
31
|
* consume { publishToken, siteId, handle }.
|
|
21
|
-
* 9. POST Worker /
|
|
32
|
+
* 9. POST Worker /publish/check to confirm foundation + runtime
|
|
22
33
|
* exist and the token's namespace claim matches.
|
|
23
|
-
* 10. POST Worker /
|
|
34
|
+
* 10. POST Worker /publish with the full payload.
|
|
24
35
|
* 11. On first-deploy create flow: write site.id + site.handle back into
|
|
25
36
|
* site.yml so subsequent deploys fast-path.
|
|
26
37
|
*
|
|
27
38
|
* Usage:
|
|
28
39
|
* uniweb deploy Normal deploy (browser may open on first deploy)
|
|
29
|
-
* uniweb deploy --skip-build Don't rebuild even if dist/ is stale
|
|
30
40
|
* uniweb deploy --dry-run Resolve everything but skip the Worker POST
|
|
31
|
-
* uniweb deploy --
|
|
32
|
-
* uniweb deploy --
|
|
33
|
-
*
|
|
41
|
+
* uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
|
|
42
|
+
* uniweb deploy --host <name> Static-host flow (e.g., s3-cloudfront,
|
|
43
|
+
* generic-static). Overrides site.yml deploy.host.
|
|
44
|
+
*
|
|
45
|
+
* Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
|
|
46
|
+
* UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
|
|
47
|
+
* UNIWEB_SKIP_ASSETS=1 Skip the asset upload step
|
|
48
|
+
* UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
|
|
49
|
+
* UNIWEB_FORCE_REVIEW=1 Force the browser review flow
|
|
50
|
+
* UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
|
|
34
51
|
*
|
|
35
52
|
* See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
|
|
36
53
|
*/
|
|
@@ -46,8 +63,20 @@ import { detectFoundationType } from '@uniweb/build'
|
|
|
46
63
|
|
|
47
64
|
import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
|
|
48
65
|
import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
|
|
66
|
+
import { parseBoolEnv } from '../utils/env.js'
|
|
49
67
|
import { RemoteRegistry } from '../utils/registry.js'
|
|
50
|
-
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
|
|
71
|
+
* Returns null on any shape we don't recognize. Inlined here after the
|
|
72
|
+
* receipt-cache utility module was removed in Phase 4b — the only
|
|
73
|
+
* remaining caller is the staleness check below.
|
|
74
|
+
*/
|
|
75
|
+
function splitRegistryRef(ref) {
|
|
76
|
+
if (typeof ref !== 'string') return null
|
|
77
|
+
const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
|
|
78
|
+
return m ? { name: m[1], version: m[2] } : null
|
|
79
|
+
}
|
|
51
80
|
import {
|
|
52
81
|
findWorkspaceRoot,
|
|
53
82
|
findSites,
|
|
@@ -215,46 +244,41 @@ function composeFoundationUrl(ref, registryBase) {
|
|
|
215
244
|
}
|
|
216
245
|
|
|
217
246
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
247
|
+
* Decide whether a workspace-local foundation is stale relative to the
|
|
248
|
+
* registry's record, by comparing per-directory git provenance against
|
|
249
|
+
* the registry entry's `publishedFromGitSha`. No local cache file —
|
|
250
|
+
* `dist/publish.json` was deleted in Phase 4b of the CLI ergonomics
|
|
251
|
+
* overhaul because every fresh clone / CI run / collaborator paid the
|
|
252
|
+
* registry round-trip anyway, and the local cache only added confusing
|
|
253
|
+
* "stale receipt" warnings when collaborators had different `dist/`
|
|
254
|
+
* state.
|
|
220
255
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* That makes the receipt pure cache: a fresh clone with a matching
|
|
224
|
-
* upstream artifact resolves without an unnecessary republish. If the
|
|
225
|
-
* registry has no record (or the stored entry lacks git provenance), the
|
|
226
|
-
* inspector returns `stale: true` and the caller's auto-publish path
|
|
227
|
-
* runs as before.
|
|
228
|
-
*
|
|
229
|
-
* Returns `{ stale, reason, receipt, refilled? }`. The caller decides
|
|
230
|
-
* whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
|
|
256
|
+
* Returns `{ stale, reason }`. The caller decides whether to auto-publish
|
|
257
|
+
* (Phase 2 default) or fail (`--no-auto-publish`).
|
|
231
258
|
*/
|
|
232
|
-
async function
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
async function inspectFoundationStaleness(localPath, { dirtyAsStale, registry, ref }) {
|
|
260
|
+
const { gitSha, gitDirty } = readGitState(localPath)
|
|
261
|
+
if (!gitSha) {
|
|
262
|
+
return { stale: true, reason: 'foundation directory is not in a git repo or has no commits' }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const split = splitRegistryRef(ref)
|
|
266
|
+
if (!split) {
|
|
267
|
+
return { stale: true, reason: 'cannot derive registry ref from package.json' }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let existingEntry
|
|
236
271
|
try {
|
|
237
|
-
|
|
272
|
+
existingEntry = await registry.getVersionEntry(split.name, split.version)
|
|
238
273
|
} catch {
|
|
239
|
-
|
|
240
|
-
const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
|
|
241
|
-
if (refill) {
|
|
242
|
-
await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
|
|
243
|
-
receipt = refill
|
|
244
|
-
refilled = true
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if (!receipt) {
|
|
248
|
-
return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
|
|
249
|
-
}
|
|
274
|
+
return { stale: true, reason: 'registry lookup failed' }
|
|
250
275
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!gitSha) {
|
|
254
|
-
return { stale: true, reason: 'foundation directory is not in a git repo or has no commits', receipt }
|
|
276
|
+
if (!existingEntry) {
|
|
277
|
+
return { stale: true, reason: `${split.name}@${split.version} not yet published` }
|
|
255
278
|
}
|
|
256
|
-
|
|
257
|
-
|
|
279
|
+
|
|
280
|
+
if (existingEntry.publishedFromGitSha && existingEntry.publishedFromGitSha !== gitSha) {
|
|
281
|
+
// Recorded sha differs from the foundation's per-directory
|
|
258
282
|
// last-touched commit. Normally that's "real" staleness — somebody
|
|
259
283
|
// committed changes to src/ that haven't been republished.
|
|
260
284
|
//
|
|
@@ -268,67 +292,17 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry
|
|
|
268
292
|
// hasn't materially changed. Don't fire staleness on the sha
|
|
269
293
|
// alone in that case; let the dirty-tree check below do its job
|
|
270
294
|
// if the tree IS still dirty, and otherwise treat as fresh.
|
|
271
|
-
|
|
272
|
-
// Edge: if the user committed real source changes ON TOP of the
|
|
273
|
-
// auto-derive in the same commit, we won't detect that as stale
|
|
274
|
-
// here — the next publish would 409 against the registry though,
|
|
275
|
-
// surfacing the issue with a clear "bump the version" message.
|
|
276
|
-
if (!receipt.publishedFromGitDirty) {
|
|
295
|
+
if (!existingEntry.publishedFromGitDirty) {
|
|
277
296
|
return {
|
|
278
297
|
stale: true,
|
|
279
|
-
reason: `foundation has new commits since last publish (${
|
|
280
|
-
receipt,
|
|
298
|
+
reason: `foundation has new commits since last publish (${existingEntry.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
|
|
281
299
|
}
|
|
282
300
|
}
|
|
283
301
|
}
|
|
284
302
|
if (gitDirty && dirtyAsStale) {
|
|
285
|
-
return { stale: true, reason: 'foundation working tree is dirty'
|
|
303
|
+
return { stale: true, reason: 'foundation working tree is dirty' }
|
|
286
304
|
}
|
|
287
|
-
return { stale: false
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* When a receipt is missing, try to reconstruct it from the registry's
|
|
292
|
-
* own record of the publish. We resolve `name@version` from the same
|
|
293
|
-
* package metadata `deriveLocalFoundationRef` uses, then ask the registry
|
|
294
|
-
* for its stored version entry. The entry's `publishedFromGitSha` is the
|
|
295
|
-
* load-bearing field — without it (entries written before git provenance
|
|
296
|
-
* was added) we can't compare against HEAD, so we don't synthesize a
|
|
297
|
-
* misleading "fresh" receipt and let the caller fall through to the
|
|
298
|
-
* republish path.
|
|
299
|
-
*
|
|
300
|
-
* Returns the receipt body, or null when refill is not possible.
|
|
301
|
-
*/
|
|
302
|
-
async function tryRefillReceiptFromRegistry({ localPath, registry }) {
|
|
303
|
-
// Two ways to derive the canonical `<name>@<version>`:
|
|
304
|
-
// 1. `deriveLocalFoundationRef` — works for org-scope foundations
|
|
305
|
-
// (where the namespace is in package.json) and for any foundation
|
|
306
|
-
// where a previous receipt already exists. On the wipe-and-deploy
|
|
307
|
-
// path that fired this code, the receipt is gone, so this only
|
|
308
|
-
// works for org-scope.
|
|
309
|
-
// 2. Empty-scope fallback — if package.json has `uniweb.id` and the
|
|
310
|
-
// user's auth.json carries a `memberUuid` claim, we can synthesize
|
|
311
|
-
// `~<memberUuid>/<id>@<version>` directly. Same shape the server
|
|
312
|
-
// stores under.
|
|
313
|
-
let ref = await deriveLocalFoundationRef(localPath)
|
|
314
|
-
if (!ref) ref = await refFromAuthAndPkg(localPath)
|
|
315
|
-
const split = splitRegistryRef(ref)
|
|
316
|
-
if (!split) return null
|
|
317
|
-
let existingEntry
|
|
318
|
-
try {
|
|
319
|
-
existingEntry = await registry.getVersionEntry(split.name, split.version)
|
|
320
|
-
} catch {
|
|
321
|
-
return null
|
|
322
|
-
}
|
|
323
|
-
if (!existingEntry) return null
|
|
324
|
-
return receiptFromRegistryEntry({
|
|
325
|
-
existingEntry,
|
|
326
|
-
registry,
|
|
327
|
-
name: split.name,
|
|
328
|
-
version: split.version,
|
|
329
|
-
isLocal: false,
|
|
330
|
-
isPropagateDefault: false,
|
|
331
|
-
})
|
|
305
|
+
return { stale: false }
|
|
332
306
|
}
|
|
333
307
|
|
|
334
308
|
/**
|
|
@@ -365,11 +339,10 @@ async function refFromAuthAndPkg(localPath) {
|
|
|
365
339
|
*
|
|
366
340
|
* Resolution order:
|
|
367
341
|
* 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
|
|
368
|
-
* 2.
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* because the memberUuid lives in the JWT, not in the workspace.
|
|
342
|
+
* 2. Empty-scope synthesis from `pkg.uniweb.id` + the user's auth claim
|
|
343
|
+
* (`~<memberUuid>/<id>@<version>`). Same canonical shape the server
|
|
344
|
+
* stores under for empty-scope publishes. Phase 4d will replace this
|
|
345
|
+
* with `~{siteId}/...` derived from authorize.
|
|
373
346
|
* 3. null — caller falls through to the helpful "set uniweb.namespace"
|
|
374
347
|
* error message.
|
|
375
348
|
*/
|
|
@@ -403,154 +376,37 @@ async function deriveLocalFoundationRef(localPath) {
|
|
|
403
376
|
return `@${namespace}/${bareName}@${version}`
|
|
404
377
|
}
|
|
405
378
|
|
|
406
|
-
// Empty-scope
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
379
|
+
// Empty-scope fallback: synthesize `~<memberUuid>/<id>@<version>` from
|
|
380
|
+
// the user's auth + package.json::uniweb.id. Same canonical shape the
|
|
381
|
+
// server stores under for empty-scope publishes. After Phase 4d this
|
|
382
|
+
// path is replaced by `~{siteId}/...` derived from authorize.
|
|
383
|
+
const fromAuth = await refFromAuthAndPkg(localPath)
|
|
384
|
+
if (fromAuth) return fromAuth
|
|
412
385
|
|
|
413
386
|
return null
|
|
414
387
|
}
|
|
415
388
|
|
|
416
|
-
// Personal-scope namespaces are base58 memberUuids (mixed case; the
|
|
417
|
-
// alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
|
|
418
|
-
// lowercase-only. Allow both shapes here so the regex captures personal
|
|
419
|
-
// scopes correctly. The bare-name portion (after the `/`) stays
|
|
420
|
-
// lowercase per BARE_NAME_RE.
|
|
421
|
-
const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
|
|
422
|
-
|
|
423
|
-
async function refFromReceiptUrl(localPath) {
|
|
424
|
-
try {
|
|
425
|
-
const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
|
|
426
|
-
const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
|
|
427
|
-
if (m) return `${m[1]}@${m[2]}`
|
|
428
|
-
} catch {
|
|
429
|
-
// No receipt, malformed JSON, or URL doesn't carry the canonical
|
|
430
|
-
// shape — fall through to null.
|
|
431
|
-
}
|
|
432
|
-
return null
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Resolve the deploy mode for this site.
|
|
437
|
-
*
|
|
438
|
-
* Returns `'link'` or `'bundle'`, optionally prompting the user when
|
|
439
|
-
* neither inference nor an explicit flag yields an answer. Mode choice
|
|
440
|
-
* is persisted server-side after the first successful deploy; switching
|
|
441
|
-
* modes later requires deleting the site and redeploying fresh.
|
|
442
|
-
*
|
|
443
|
-
* Ladder (first match wins):
|
|
444
|
-
* 1. `--link` or `--bundle` flag passed explicitly.
|
|
445
|
-
* 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
|
|
446
|
-
* `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
|
|
447
|
-
* declared "load this foundation by URL at runtime."
|
|
448
|
-
* 3. `site.yml::foundation` is a workspace-local sibling AND that
|
|
449
|
-
* foundation has a publish receipt (`dist/publish.json` with a
|
|
450
|
-
* registry URL) → infer link. The user has already done the work
|
|
451
|
-
* to make the foundation loadable from the registry.
|
|
452
|
-
* 4. Otherwise → ask (TTY prompt) or error (CI).
|
|
453
|
-
*
|
|
454
|
-
* @param {Object} ctx
|
|
455
|
-
* @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
|
|
456
|
-
* @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
|
|
457
|
-
* @param {boolean} ctx.linkFlag
|
|
458
|
-
* @param {boolean} ctx.bundleFlag
|
|
459
|
-
* @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
|
|
460
|
-
*/
|
|
461
|
-
async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
|
|
462
|
-
// 1. Explicit flag wins.
|
|
463
|
-
if (linkFlag) return { mode: 'link', source: 'flag' }
|
|
464
|
-
if (bundleFlag) return { mode: 'bundle', source: 'flag' }
|
|
465
|
-
|
|
466
|
-
// 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
|
|
467
|
-
if (typeof foundationRef === 'string') {
|
|
468
|
-
if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
|
|
469
|
-
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
470
|
-
}
|
|
471
|
-
if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
472
|
-
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
473
|
-
}
|
|
474
|
-
if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
475
|
-
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// 3. Workspace-local foundation with an existing publish receipt → infer link.
|
|
480
|
-
// The receipt being there means the user has already published this
|
|
481
|
-
// foundation at least once, so they've shown intent to ship via the
|
|
482
|
-
// registry. We don't validate the receipt's freshness here — that's
|
|
483
|
-
// the existing inspectLocalFoundationReceipt path's job, which runs
|
|
484
|
-
// later in the deploy flow.
|
|
485
|
-
if (typeof foundationRef === 'string') {
|
|
486
|
-
const detected = detectFoundationType(foundationRef, siteDir)
|
|
487
|
-
if (detected.type === 'local') {
|
|
488
|
-
const receiptPath = join(detected.path, 'dist', 'publish.json')
|
|
489
|
-
if (existsSync(receiptPath)) {
|
|
490
|
-
return { mode: 'link', source: 'inferred-published-local' }
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// 4. Ambiguous — local foundation never published, or unknown shape. Ask.
|
|
496
|
-
if (isNonInteractive(process.argv)) {
|
|
497
|
-
say.err('First deploy of this site needs an explicit mode.')
|
|
498
|
-
console.log('')
|
|
499
|
-
console.log(' Pick one and re-run:')
|
|
500
|
-
console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
|
|
501
|
-
console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
|
|
502
|
-
console.log('')
|
|
503
|
-
console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
|
|
504
|
-
process.exit(1)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const prompts = (await import('prompts')).default
|
|
508
|
-
console.log('')
|
|
509
|
-
console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
|
|
510
|
-
const resp = await prompts({
|
|
511
|
-
type: 'select',
|
|
512
|
-
name: 'mode',
|
|
513
|
-
message: 'Deployment mode',
|
|
514
|
-
choices: [
|
|
515
|
-
{ title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
|
|
516
|
-
{ title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
|
|
517
|
-
],
|
|
518
|
-
initial: 0,
|
|
519
|
-
}, {
|
|
520
|
-
onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
|
|
521
|
-
})
|
|
522
|
-
if (!resp.mode) process.exit(0)
|
|
523
|
-
return { mode: resp.mode, source: 'asked' }
|
|
524
|
-
}
|
|
525
|
-
|
|
526
389
|
// ─── Main ───────────────────────────────────────────────────
|
|
527
390
|
|
|
528
391
|
export async function deploy(args = []) {
|
|
529
|
-
const skipBuild = args.includes('--skip-build')
|
|
530
392
|
const dryRun = args.includes('--dry-run')
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
//
|
|
534
|
-
// Useful for "I want to look at / change my features" without first
|
|
535
|
-
// having to edit site.yml. The toggles in the review page persist live
|
|
536
|
-
// to DB, so any change the user makes there ends up in site.yml after
|
|
537
|
-
// the loopback finalize roundtrip.
|
|
538
|
-
const forceReview = args.includes('--review')
|
|
539
|
-
// Phase 2 (deploy-ux-v4): when `foundation:` in site.yml points at a
|
|
540
|
-
// workspace-local file: ref, deploy auto-publishes the foundation when
|
|
541
|
-
// its `dist/publish.json` receipt is missing/stale. These flags opt out.
|
|
393
|
+
// When `foundation:` in site.yml points at a workspace-local file: ref,
|
|
394
|
+
// deploy auto-publishes the foundation when the registry has no record
|
|
395
|
+
// of the current source's git sha. This flag opts out.
|
|
542
396
|
const autoPublishFoundation = !args.includes('--no-auto-publish')
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
397
|
+
|
|
398
|
+
// Internal escape hatches — see framework/cli/docs/env-vars.md. These
|
|
399
|
+
// are not user-facing flags; they exist for the platform test team,
|
|
400
|
+
// CI scripts, and dev-loop unblockers. The bare `deploy` command should
|
|
401
|
+
// do the right thing for normal users without any of them set.
|
|
402
|
+
const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
|
|
403
|
+
const skipAssets = parseBoolEnv('UNIWEB_SKIP_ASSETS')
|
|
404
|
+
const skipBilling = parseBoolEnv('UNIWEB_SKIP_BILLING')
|
|
405
|
+
const forceReview = parseBoolEnv('UNIWEB_FORCE_REVIEW')
|
|
406
|
+
// Inverse of the (now-removed) --no-dirty-as-stale flag. When true, a
|
|
407
|
+
// dirty workspace will NOT be treated as stale (won't trigger auto-publish
|
|
408
|
+
// of the foundation). Default: dirty IS stale.
|
|
409
|
+
const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
|
|
554
410
|
|
|
555
411
|
const siteDir = await resolveSiteDir(args)
|
|
556
412
|
const backendUrl = getBackendUrl()
|
|
@@ -560,6 +416,22 @@ export async function deploy(args = []) {
|
|
|
560
416
|
// site.id / site.handle from prior deploys.
|
|
561
417
|
const siteYmlPath = join(siteDir, 'site.yml')
|
|
562
418
|
const siteYml = await readSiteYml(siteYmlPath)
|
|
419
|
+
|
|
420
|
+
// Host dispatch. The default host is `uniweb` — Uniweb hosting
|
|
421
|
+
// (link-mode + edge JIT prerender), which is the rest of this
|
|
422
|
+
// function. Any other named host is a static-host adapter; hand off
|
|
423
|
+
// to it and return. The default flow requires a `foundation:`
|
|
424
|
+
// declaration; static-host deploys don't, so this branch comes BEFORE
|
|
425
|
+
// the foundation check.
|
|
426
|
+
// See kb/framework/plans/static-host-deploy-adapters.md.
|
|
427
|
+
const hostFlagIndex = args.indexOf('--host')
|
|
428
|
+
const hostFromFlag = hostFlagIndex !== -1 ? args[hostFlagIndex + 1] : null
|
|
429
|
+
const host = hostFromFlag || siteYml.deploy?.host || 'uniweb'
|
|
430
|
+
if (host !== 'uniweb') {
|
|
431
|
+
await deployStaticHost(siteDir, siteYml, host, { dryRun })
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
563
435
|
if (!siteYml.foundation) {
|
|
564
436
|
say.err('site.yml is missing `foundation`.')
|
|
565
437
|
say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
|
|
@@ -583,29 +455,29 @@ export async function deploy(args = []) {
|
|
|
583
455
|
say.dim('Foundation policy: exact (pinned)')
|
|
584
456
|
}
|
|
585
457
|
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
siteDir
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
say.dim(
|
|
601
|
-
|
|
602
|
-
say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
|
|
603
|
-
} else if (modeSource === 'asked') {
|
|
604
|
-
say.dim(`Deploy mode: ${deployMode} (selected)`)
|
|
605
|
-
} else {
|
|
606
|
-
say.dim(`Deploy mode: ${deployMode}`)
|
|
458
|
+
// --dry-run gate. Must come BEFORE auto-publish (which writes to the
|
|
459
|
+
// registry) and BEFORE the site build (which writes to dist/). Earlier
|
|
460
|
+
// versions of this command had the dry-run check after both, which
|
|
461
|
+
// violated the contract that --dry-run performs zero writes. Languages
|
|
462
|
+
// and the default locale are unavailable here (they live in
|
|
463
|
+
// dist/site-content.json, which a dry-run won't build); the trade-off
|
|
464
|
+
// is intentional. Run `uniweb build` directly if you need that detail.
|
|
465
|
+
if (dryRun) {
|
|
466
|
+
say.info('Dry run — would deploy:')
|
|
467
|
+
say.dim(`Site dir : ${siteDir}`)
|
|
468
|
+
say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
|
|
469
|
+
say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
|
|
470
|
+
say.dim(`Runtime : ${siteYml.runtime || '(latest, resolved at authorize)'}`)
|
|
471
|
+
say.dim(`Backend (PHP) : ${backendUrl}`)
|
|
472
|
+
say.dim(`Worker : ${workerUrl}`)
|
|
473
|
+
return
|
|
607
474
|
}
|
|
608
475
|
|
|
476
|
+
// `uniweb deploy` always runtime-links: the edge serves a runtime
|
|
477
|
+
// template + per-site base.html, with the foundation loaded by URL.
|
|
478
|
+
// The historical --link / --bundle flags are gone (Phase 2 of the CLI
|
|
479
|
+
// ergonomics overhaul). For static-host artifacts, see `uniweb export`.
|
|
480
|
+
|
|
609
481
|
// Phase 2: resolve workspace-local `file:` foundation refs.
|
|
610
482
|
//
|
|
611
483
|
// The object form of `foundation:` already requires a registry ref
|
|
@@ -616,69 +488,59 @@ export async function deploy(args = []) {
|
|
|
616
488
|
// build runs in runtime mode against the just-published artifact instead
|
|
617
489
|
// of bundling the local foundation source. site.yml on disk is never
|
|
618
490
|
// modified.
|
|
619
|
-
|
|
491
|
+
// Phase 4d: detect a workspace-local foundation. The actual upload happens
|
|
492
|
+
// AFTER authorize (which mints siteId), so the canonical site-bound ref
|
|
493
|
+
// `~{siteId}/{name}@{ver}` is known by the time we publish. For now we
|
|
494
|
+
// just record what we'll need at upload time and pass a `~self/...`
|
|
495
|
+
// placeholder to authorize — the server rewrites it.
|
|
496
|
+
let localFoundation = null
|
|
620
497
|
if (typeof foundation === 'string') {
|
|
621
498
|
const detected = detectFoundationType(foundation, siteDir)
|
|
622
499
|
if (detected.type === 'local') {
|
|
623
500
|
const localPath = detected.path
|
|
624
501
|
const relPath = relative(siteDir, localPath) || localPath
|
|
625
502
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
dirtyAsStale: treatDirtyAsStale,
|
|
632
|
-
registry: refillRegistry,
|
|
633
|
-
})
|
|
634
|
-
if (inspection.refilled) {
|
|
635
|
-
say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
if (inspection.stale && !autoPublishFoundation) {
|
|
639
|
-
say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
|
|
640
|
-
say.dim(`Run \`${getCliPrefix()} publish\` from ${relPath}, or drop --no-auto-publish to let deploy publish it for you.`)
|
|
503
|
+
let pkg
|
|
504
|
+
try {
|
|
505
|
+
pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
|
|
506
|
+
} catch {
|
|
507
|
+
say.err(`Could not read ${relPath}/package.json.`)
|
|
641
508
|
process.exit(1)
|
|
642
509
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// `npx uniweb` — npx resolves through node_modules and could
|
|
649
|
-
// pick up a stale npm-published version that doesn't share
|
|
650
|
-
// this CLI's behavior (e.g. doesn't recognize new flags).
|
|
651
|
-
// Using `process.argv[1]` keeps the outer/inner CLI version
|
|
652
|
-
// identical, eliminating the skew.
|
|
653
|
-
execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
|
|
654
|
-
cwd: localPath,
|
|
655
|
-
stdio: 'inherit',
|
|
656
|
-
})
|
|
657
|
-
} catch {
|
|
658
|
-
say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
|
|
659
|
-
process.exit(1)
|
|
660
|
-
}
|
|
661
|
-
console.log('')
|
|
510
|
+
const foundationName = pkg.uniweb?.id || pkg.name?.replace(/^[@~][^/]+\//, '') || pkg.name
|
|
511
|
+
const foundationVersion = pkg.version
|
|
512
|
+
if (!foundationName || !foundationVersion) {
|
|
513
|
+
say.err(`Foundation at ${relPath} needs both a name and a version in package.json.`)
|
|
514
|
+
process.exit(1)
|
|
662
515
|
}
|
|
663
516
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
517
|
+
localFoundation = {
|
|
518
|
+
path: localPath,
|
|
519
|
+
relPath,
|
|
520
|
+
name: foundationName,
|
|
521
|
+
version: foundationVersion,
|
|
669
522
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
523
|
+
|
|
524
|
+
// Send `~self/{name}@{ver}` as a placeholder. The server will rewrite
|
|
525
|
+
// to `~{siteId}/{name}@{ver}` once siteId is minted. The CLI uses the
|
|
526
|
+
// returned canonical ref for both the upload and the publish payload.
|
|
527
|
+
foundation = `~self/${foundationName}@${foundationVersion}`
|
|
673
528
|
}
|
|
674
529
|
}
|
|
530
|
+
// Honor --no-auto-publish for local foundations: surface the gate before
|
|
531
|
+
// we do any work.
|
|
532
|
+
if (localFoundation && !autoPublishFoundation) {
|
|
533
|
+
say.err(`Local foundation at ${localFoundation.relPath} would be auto-published as part of deploy.`)
|
|
534
|
+
say.dim('Drop --no-auto-publish to let deploy publish it, or change site.yml to reference a registry-published foundation.')
|
|
535
|
+
process.exit(1)
|
|
536
|
+
}
|
|
675
537
|
|
|
676
538
|
// Runtime defaults to "latest" resolved at authorize time.
|
|
677
539
|
let runtimeVersion = siteYml.runtime
|
|
678
540
|
if (!runtimeVersion) {
|
|
679
541
|
runtimeVersion = await fetchLatestRuntime(workerUrl)
|
|
680
542
|
if (!runtimeVersion) {
|
|
681
|
-
say.err('Could not resolve a runtime version (no runtime: in site.yml, /
|
|
543
|
+
say.err('Could not resolve a runtime version (no runtime: in site.yml, /runtime/latest failed).')
|
|
682
544
|
process.exit(1)
|
|
683
545
|
}
|
|
684
546
|
say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
|
|
@@ -705,32 +567,29 @@ export async function deploy(args = []) {
|
|
|
705
567
|
// detectFoundationType recognizes `@ns/name@version` refs as
|
|
706
568
|
// link-mode URLs, which auto-enters runtime mode. Prerender also
|
|
707
569
|
// auto-skips for link-mode foundations (HTML is rendered on the
|
|
708
|
-
// serving edge, not here).
|
|
570
|
+
// serving edge, not here). Always --link: the edge serves a runtime
|
|
571
|
+
// template + per-site base.html, never a self-contained vite bundle.
|
|
709
572
|
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
// the
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// there but still passed through for consistency.
|
|
573
|
+
// Phase 4d: workspace-local foundations carry the `~self/{name}@{ver}`
|
|
574
|
+
// placeholder at this point; the canonical `~{siteId}/...` ref isn't
|
|
575
|
+
// known until authorize returns. Link mode doesn't run vite or fetch
|
|
576
|
+
// the foundation, so site-content.json's foundation field reflects
|
|
577
|
+
// whatever's in site.yml — that's fine because the publish payload
|
|
578
|
+
// overrides it with the canonical form post-authorize.
|
|
717
579
|
//
|
|
718
580
|
// Spawn the SAME CLI binary that's currently running rather than
|
|
719
581
|
// `npx uniweb build` — npx walks node_modules and would resolve to
|
|
720
582
|
// whatever version is installed there (which might be older than
|
|
721
583
|
// the deploy CLI and silently ignore --link). `process.argv[1]`
|
|
722
584
|
// pins the inner build to the outer's exact version.
|
|
723
|
-
|
|
724
|
-
execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
|
|
585
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
|
|
725
586
|
cwd: siteDir,
|
|
726
587
|
stdio: 'inherit',
|
|
727
|
-
env:
|
|
728
|
-
? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
|
|
729
|
-
: process.env,
|
|
588
|
+
env: process.env,
|
|
730
589
|
})
|
|
731
590
|
console.log('')
|
|
732
591
|
} else if (!existsSync(contentPath)) {
|
|
733
|
-
say.err('No build found and
|
|
592
|
+
say.err('No build found and UNIWEB_SKIP_BUILD set. Run `uniweb build` first.')
|
|
734
593
|
process.exit(1)
|
|
735
594
|
}
|
|
736
595
|
if (!existsSync(contentPath)) {
|
|
@@ -762,25 +621,13 @@ export async function deploy(args = []) {
|
|
|
762
621
|
}
|
|
763
622
|
}
|
|
764
623
|
|
|
765
|
-
if (dryRun) {
|
|
766
|
-
say.info('Dry run — showing what would be deployed:')
|
|
767
|
-
say.dim(`Site dir : ${siteDir}`)
|
|
768
|
-
say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
|
|
769
|
-
say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
|
|
770
|
-
say.dim(`Runtime : ${runtimeVersion}`)
|
|
771
|
-
say.dim(`Languages : ${languages.join(', ')}`)
|
|
772
|
-
say.dim(`Default locale : ${defaultLanguage}`)
|
|
773
|
-
say.dim(`Backend (PHP) : ${backendUrl}`)
|
|
774
|
-
say.dim(`Worker : ${workerUrl}`)
|
|
775
|
-
return
|
|
776
|
-
}
|
|
777
|
-
|
|
778
624
|
// Spin up the loopback listener eagerly — we need its callback URL for the
|
|
779
625
|
// authorize request even on the fast path (PHP may always return
|
|
780
626
|
// needsReview=true on first deploy / billing drift in future phases).
|
|
781
627
|
const loopback = await startLoopback()
|
|
782
628
|
|
|
783
629
|
let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl, mintedFeatures
|
|
630
|
+
let foundationUploadUrl // Phase 4d: returned by authorize for site-bound foundation uploads
|
|
784
631
|
try {
|
|
785
632
|
say.info('Requesting deploy authorization…')
|
|
786
633
|
const authorizeBody = {
|
|
@@ -805,16 +652,9 @@ export async function deploy(args = []) {
|
|
|
805
652
|
// Always sent as an array; missing/empty `features:` in site.yml
|
|
806
653
|
// is normalized to `[]`, meaning "no paid features".
|
|
807
654
|
desiredFeatures,
|
|
808
|
-
// User-forced review (
|
|
655
|
+
// User-forced review (UNIWEB_FORCE_REVIEW=1). PHP refuses to
|
|
809
656
|
// fast-path even when nothing else has drifted.
|
|
810
657
|
forceReview: forceReview || undefined,
|
|
811
|
-
// Deploy mode for this site. On first deploy PHP should persist
|
|
812
|
-
// it to the site row; on subsequent deploys PHP should return the
|
|
813
|
-
// persisted value back so the CLI can detect mode mismatches and
|
|
814
|
-
// refuse with "delete and redeploy fresh" rather than silently
|
|
815
|
-
// reshape the storage layout. PHP versions that pre-date mode
|
|
816
|
-
// persistence ignore this field — back-compat is built in.
|
|
817
|
-
mode: deployMode,
|
|
818
658
|
}
|
|
819
659
|
let authRes
|
|
820
660
|
try {
|
|
@@ -829,27 +669,18 @@ export async function deploy(args = []) {
|
|
|
829
669
|
say.dim('Treating as a new site — the create flow will run in your browser.')
|
|
830
670
|
authorizeBody.siteId = ''
|
|
831
671
|
authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
|
|
672
|
+
} else if (err.status === 403 && authorizeBody.siteId) {
|
|
673
|
+
// Collaborator ACL — the user has the repo (and thus site.id in
|
|
674
|
+
// site.yml) but isn't owner or editor on this site. The server's
|
|
675
|
+
// 403 message names the owner; surface it verbatim.
|
|
676
|
+
say.err(err.message)
|
|
677
|
+
process.exit(1)
|
|
832
678
|
} else {
|
|
833
679
|
say.err(`Authorize failed: ${err.message}`)
|
|
834
680
|
process.exit(1)
|
|
835
681
|
}
|
|
836
682
|
}
|
|
837
683
|
|
|
838
|
-
// Mode lock — once a site has a persisted mode on the server,
|
|
839
|
-
// deploying with a different mode would silently reshape its R2
|
|
840
|
-
// storage layout. Refuse and direct the user to start fresh.
|
|
841
|
-
// Until PHP starts returning `persistedMode`, this check is a
|
|
842
|
-
// no-op (forward-compatible).
|
|
843
|
-
if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
|
|
844
|
-
say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
|
|
845
|
-
console.log('')
|
|
846
|
-
console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
|
|
847
|
-
console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
|
|
848
|
-
console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
|
|
849
|
-
console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
|
|
850
|
-
process.exit(1)
|
|
851
|
-
}
|
|
852
|
-
|
|
853
684
|
if (authRes.needsReview) {
|
|
854
685
|
const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
|
|
855
686
|
// openBrowser returns a hint about whether a GUI was available. On
|
|
@@ -889,16 +720,29 @@ export async function deploy(args = []) {
|
|
|
889
720
|
// CLI can write `features:` back into site.yml accurately. Older
|
|
890
721
|
// PHP that doesn't include this field is a no-op.
|
|
891
722
|
mintedFeatures = Array.isArray(cb.features) ? cb.features : null
|
|
723
|
+
// Phase 4d: workspace-local foundation deploys on the create flow
|
|
724
|
+
// need the rewritten `~{siteId}/{name}@{ver}` ref + upload endpoint.
|
|
725
|
+
// PHP/unicloud put them in the finalize response; the web app
|
|
726
|
+
// forwards them to the loopback. Catalog-ref deploys leave them
|
|
727
|
+
// undefined and we fall back to the placeholder/derived URL below.
|
|
728
|
+
if (cb.foundationRef) foundation = cb.foundationRef
|
|
729
|
+
if (cb.foundationUploadUrl) foundationUploadUrl = cb.foundationUploadUrl
|
|
892
730
|
// Review path: Worker URLs are implicit (we derive them from config).
|
|
893
|
-
publishUrl = `${workerUrl}/
|
|
894
|
-
validateUrl = `${workerUrl}/
|
|
731
|
+
publishUrl = `${workerUrl}/publish`
|
|
732
|
+
validateUrl = `${workerUrl}/publish/check`
|
|
895
733
|
} else {
|
|
896
734
|
publishToken = authRes.publishToken
|
|
897
735
|
siteIdResolved = authRes.siteId
|
|
898
736
|
handleResolved = authRes.handle
|
|
899
737
|
publishUrl = authRes.publishUrl
|
|
900
738
|
validateUrl = authRes.validateUrl
|
|
739
|
+
foundationUploadUrl = authRes.foundationUploadUrl
|
|
901
740
|
mintedFeatures = Array.isArray(authRes.features) ? authRes.features : null
|
|
741
|
+
// Phase 4d: server returns the canonical foundation ref. For
|
|
742
|
+
// `~self/...` placeholders this is the rewritten `~{siteId}/...`
|
|
743
|
+
// form; catalog refs pass through. The CLI uses this for both the
|
|
744
|
+
// foundation upload (next step) and the publish payload below.
|
|
745
|
+
if (authRes.foundationRef) foundation = authRes.foundationRef
|
|
902
746
|
}
|
|
903
747
|
} finally {
|
|
904
748
|
loopback.close()
|
|
@@ -918,6 +762,45 @@ export async function deploy(args = []) {
|
|
|
918
762
|
say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
|
|
919
763
|
}
|
|
920
764
|
|
|
765
|
+
// Phase 4d: upload site-bound foundation files directly. Replaces the
|
|
766
|
+
// pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
|
|
767
|
+
// canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
|
|
768
|
+
// /foundations endpoint accepts the publish token's siteId claim
|
|
769
|
+
// for this scope.
|
|
770
|
+
if (localFoundation) {
|
|
771
|
+
say.info(`Building foundation at ${localFoundation.relPath}…`)
|
|
772
|
+
console.log('')
|
|
773
|
+
try {
|
|
774
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build`, {
|
|
775
|
+
cwd: localFoundation.path,
|
|
776
|
+
stdio: 'inherit',
|
|
777
|
+
})
|
|
778
|
+
} catch {
|
|
779
|
+
say.err(`Foundation build at ${localFoundation.relPath} failed. See output above.`)
|
|
780
|
+
process.exit(1)
|
|
781
|
+
}
|
|
782
|
+
console.log('')
|
|
783
|
+
|
|
784
|
+
say.info(`Uploading foundation as ${foundation}…`)
|
|
785
|
+
const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
|
|
786
|
+
const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/foundations`
|
|
787
|
+
const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
|
|
788
|
+
await callFoundationUpload({
|
|
789
|
+
url: foundationPublishUrl,
|
|
790
|
+
token: publishToken,
|
|
791
|
+
body: {
|
|
792
|
+
name: foundation.replace(/@[^@]+$/, ''), // strip `@version` to get `~{siteId}/{name}`
|
|
793
|
+
version: localFoundation.version,
|
|
794
|
+
files: foundationFiles,
|
|
795
|
+
metadata: {
|
|
796
|
+
...(fGitSha ? { publishedFromGitSha: fGitSha } : {}),
|
|
797
|
+
...(typeof fGitDirty === 'boolean' ? { publishedFromGitDirty: fGitDirty } : {}),
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
})
|
|
801
|
+
say.ok(`Foundation uploaded.`)
|
|
802
|
+
}
|
|
803
|
+
|
|
921
804
|
// Pre-flight against the Worker. Surfaces "foundation not published" /
|
|
922
805
|
// "runtime not found" / namespace mismatch BEFORE we ship content.
|
|
923
806
|
say.info('Validating foundation + runtime…')
|
|
@@ -935,36 +818,60 @@ export async function deploy(args = []) {
|
|
|
935
818
|
process.exit(1)
|
|
936
819
|
}
|
|
937
820
|
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
821
|
+
// Collect compiled collection JSON files from dist/data/. The framework
|
|
822
|
+
// emits these for `collection:` data sources — `<name>.json` cascade
|
|
823
|
+
// payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
|
|
824
|
+
// declared. Editor publish has no equivalent (collections live in the DB);
|
|
825
|
+
// CLI sites need them shipped as static R2 objects.
|
|
826
|
+
//
|
|
827
|
+
// Read BEFORE the asset pipeline so the asset scan can pick up image
|
|
828
|
+
// refs in collection JSON (e.g. `article.image: "/covers/foo.svg"`)
|
|
829
|
+
// and the rewrite can swap them for CDN URLs alongside locale content.
|
|
830
|
+
const dataFiles = await collectDataFiles(distDir)
|
|
831
|
+
// Decode each data file as JSON so the asset scan can walk the tree;
|
|
832
|
+
// mutated in place by the rewrite step. Re-stringified before publish.
|
|
833
|
+
const dataFileObjects = {}
|
|
834
|
+
for (const [k, raw] of Object.entries(dataFiles)) {
|
|
835
|
+
try {
|
|
836
|
+
dataFileObjects[k] = JSON.parse(raw)
|
|
837
|
+
} catch {
|
|
838
|
+
dataFileObjects[k] = null // unparseable — skip rewrite, ship as-is
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (Object.keys(dataFiles).length > 0) {
|
|
842
|
+
say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Asset pipeline — upload dist/assets/* + favicon + fonts + content-scan
|
|
846
|
+
// hits (public/, data file refs) to S3, then rewrite each locale's
|
|
847
|
+
// siteContent + each parsed data file so the runtime resolves CDN URLs at
|
|
848
|
+
// render time. Assets are locale-shared (they live in dist/assets/ +
|
|
849
|
+
// public/ regardless of language); diff/upload runs once and the rewrite
|
|
850
|
+
// walks every locale's content tree + every data-file JSON tree.
|
|
943
851
|
// Skipped with --skip-assets.
|
|
944
852
|
if (!skipAssets) {
|
|
945
853
|
await uploadAssetsAndRewriteContent({
|
|
946
854
|
siteDir,
|
|
947
855
|
localeContents,
|
|
856
|
+
dataFileObjects,
|
|
948
857
|
siteYml,
|
|
949
858
|
theme,
|
|
950
859
|
backendUrl,
|
|
951
860
|
cliToken,
|
|
952
861
|
siteId: siteIdResolved,
|
|
953
862
|
})
|
|
863
|
+
// Re-stringify any data-file JSON that the rewrite step mutated, so the
|
|
864
|
+
// publish payload below sees the rewritten URLs. Untouched files round-
|
|
865
|
+
// trip identically.
|
|
866
|
+
for (const k of Object.keys(dataFiles)) {
|
|
867
|
+
if (dataFileObjects[k] !== null) {
|
|
868
|
+
dataFiles[k] = JSON.stringify(dataFileObjects[k])
|
|
869
|
+
}
|
|
870
|
+
}
|
|
954
871
|
} else {
|
|
955
872
|
say.dim('Skipping asset upload (--skip-assets).')
|
|
956
873
|
}
|
|
957
874
|
|
|
958
|
-
// Collect compiled collection JSON files from dist/data/. The framework
|
|
959
|
-
// emits these for `collection:` data sources — `<name>.json` cascade
|
|
960
|
-
// payloads plus per-record `<name>/<slug>.json` files when `deferred:` is
|
|
961
|
-
// declared. Editor publish has no equivalent (collections live in the DB);
|
|
962
|
-
// CLI sites need them shipped as static R2 objects.
|
|
963
|
-
const dataFiles = await collectDataFiles(distDir)
|
|
964
|
-
if (Object.keys(dataFiles).length > 0) {
|
|
965
|
-
say.dim(`Data files : ${Object.keys(dataFiles).length} (collection JSON)`)
|
|
966
|
-
}
|
|
967
|
-
|
|
968
875
|
say.info('Publishing…')
|
|
969
876
|
const publishPayload = {
|
|
970
877
|
foundation,
|
|
@@ -1039,6 +946,107 @@ export async function deploy(args = []) {
|
|
|
1039
946
|
}
|
|
1040
947
|
}
|
|
1041
948
|
|
|
949
|
+
// ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
|
|
950
|
+
//
|
|
951
|
+
// Distinct from the uniweb-edge flow above. Picked when site.yml's
|
|
952
|
+
// `deploy.host` (or --host flag) names a static-host adapter
|
|
953
|
+
// registered in @uniweb/build/hosts. Always runs `uniweb build`
|
|
954
|
+
// (bundle mode + prerender) first, then hands dist/ to the adapter's
|
|
955
|
+
// deploy hook for upload + invalidation.
|
|
956
|
+
//
|
|
957
|
+
// See kb/framework/plans/static-host-deploy-adapters.md.
|
|
958
|
+
|
|
959
|
+
async function deployStaticHost(siteDir, siteYml, hostName, { dryRun }) {
|
|
960
|
+
let getAdapter
|
|
961
|
+
try {
|
|
962
|
+
({ getAdapter } = await import('@uniweb/build/hosts'))
|
|
963
|
+
} catch (err) {
|
|
964
|
+
say.err('Failed to load host adapter registry from @uniweb/build/hosts.')
|
|
965
|
+
say.dim(err.message)
|
|
966
|
+
process.exit(1)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
let adapter
|
|
970
|
+
try {
|
|
971
|
+
adapter = getAdapter(hostName)
|
|
972
|
+
} catch (err) {
|
|
973
|
+
say.err(err.message)
|
|
974
|
+
say.dim(`Set deploy.host in site.yml or pass --host=<name>. See \`uniweb deploy --help\`.`)
|
|
975
|
+
process.exit(1)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (typeof adapter.deploy !== 'function') {
|
|
979
|
+
say.err(`Host adapter '${hostName}' does not implement a deploy step.`)
|
|
980
|
+
say.dim(`Build with \`uniweb build --host=${hostName}\` and upload \`dist/\` manually,`)
|
|
981
|
+
say.dim(`or use a host whose adapter ships a deploy hook (e.g., s3-cloudfront).`)
|
|
982
|
+
process.exit(1)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const deployConfig = siteYml.deploy || {}
|
|
986
|
+
const distDir = join(siteDir, 'dist')
|
|
987
|
+
|
|
988
|
+
if (dryRun) {
|
|
989
|
+
say.info(`Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`)
|
|
990
|
+
say.dim(`Site dir : ${siteDir}`)
|
|
991
|
+
say.dim(`dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`)
|
|
992
|
+
say.dim(`deploy.bucket : ${deployConfig.bucket || '(unset)'}`)
|
|
993
|
+
say.dim(`deploy.distId : ${deployConfig.distributionId || '(unset)'}`)
|
|
994
|
+
say.dim(`deploy.region : ${deployConfig.region || '(unset)'}`)
|
|
995
|
+
say.dim(`deploy.profile : ${deployConfig.profile || '(default AWS chain)'}`)
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Always rebuild — the static-host flow expects fresh dist/ on every
|
|
1000
|
+
// deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
|
|
1001
|
+
// existing build (mirrors the uniweb-edge flow's escape hatch).
|
|
1002
|
+
const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
|
|
1003
|
+
if (skipBuild) {
|
|
1004
|
+
if (!existsSync(distDir)) {
|
|
1005
|
+
say.err('UNIWEB_SKIP_BUILD is set but dist/ does not exist.')
|
|
1006
|
+
process.exit(1)
|
|
1007
|
+
}
|
|
1008
|
+
say.info('UNIWEB_SKIP_BUILD set — reusing existing dist/.')
|
|
1009
|
+
} else {
|
|
1010
|
+
say.info(`Building site (host: ${adapter.name})…`)
|
|
1011
|
+
console.log('')
|
|
1012
|
+
try {
|
|
1013
|
+
execSync(
|
|
1014
|
+
`node ${JSON.stringify(process.argv[1])} build --bundle --host ${JSON.stringify(adapter.name)}`,
|
|
1015
|
+
{ cwd: siteDir, stdio: 'inherit' }
|
|
1016
|
+
)
|
|
1017
|
+
} catch {
|
|
1018
|
+
say.err('Build failed. See output above.')
|
|
1019
|
+
process.exit(1)
|
|
1020
|
+
}
|
|
1021
|
+
if (!existsSync(distDir)) {
|
|
1022
|
+
say.err('Build did not produce dist/.')
|
|
1023
|
+
process.exit(1)
|
|
1024
|
+
}
|
|
1025
|
+
console.log('')
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Hand off to the adapter. DeployError is the structured shape from
|
|
1029
|
+
// @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
|
|
1030
|
+
try {
|
|
1031
|
+
await adapter.deploy({
|
|
1032
|
+
distDir,
|
|
1033
|
+
deployConfig,
|
|
1034
|
+
env: process.env,
|
|
1035
|
+
log: (m) => console.log(m),
|
|
1036
|
+
})
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
if (err && err.name === 'DeployError') {
|
|
1039
|
+
say.err(err.message)
|
|
1040
|
+
if (err.hint) {
|
|
1041
|
+
console.log('')
|
|
1042
|
+
console.log(err.hint)
|
|
1043
|
+
}
|
|
1044
|
+
process.exit(1)
|
|
1045
|
+
}
|
|
1046
|
+
throw err
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1042
1050
|
// ─── site.yml ──────────────────────────────────────────────
|
|
1043
1051
|
|
|
1044
1052
|
async function readSiteYml(path) {
|
|
@@ -1115,7 +1123,11 @@ async function writeSiteYmlUpdates(path, current, updates) {
|
|
|
1115
1123
|
|
|
1116
1124
|
// ─── Resolve site dir + runtime ────────────────────────────
|
|
1117
1125
|
|
|
1118
|
-
|
|
1126
|
+
// Exported so `uniweb export` (commands/export.js) can reuse the same
|
|
1127
|
+
// site-discovery logic without duplicating it. `verb` is the command
|
|
1128
|
+
// being run ("deploy" or "export"); it appears in the error messages
|
|
1129
|
+
// so the user gets accurate guidance.
|
|
1130
|
+
export async function resolveSiteDir(args, verb = 'deploy') {
|
|
1119
1131
|
const cwd = process.cwd()
|
|
1120
1132
|
const prefix = getCliPrefix()
|
|
1121
1133
|
|
|
@@ -1128,16 +1140,16 @@ async function resolveSiteDir(args) {
|
|
|
1128
1140
|
if (sites.length === 1) return resolve(workspaceRoot, sites[0])
|
|
1129
1141
|
if (sites.length > 1) {
|
|
1130
1142
|
if (isNonInteractive(args)) {
|
|
1131
|
-
say.err(
|
|
1143
|
+
say.err(`Multiple sites found. Specify which one to ${verb}.`)
|
|
1132
1144
|
console.log('')
|
|
1133
1145
|
for (const s of sites) {
|
|
1134
|
-
console.log(` ${c.cyan}cd ${s} && ${prefix}
|
|
1146
|
+
console.log(` ${c.cyan}cd ${s} && ${prefix} ${verb}${c.reset}`)
|
|
1135
1147
|
}
|
|
1136
1148
|
process.exit(1)
|
|
1137
1149
|
}
|
|
1138
1150
|
const choice = await promptSelect('Which site?', sites)
|
|
1139
1151
|
if (!choice) {
|
|
1140
|
-
console.log(
|
|
1152
|
+
console.log(`\n${verb.charAt(0).toUpperCase() + verb.slice(1)} cancelled.`)
|
|
1141
1153
|
process.exit(0)
|
|
1142
1154
|
}
|
|
1143
1155
|
return resolve(workspaceRoot, choice)
|
|
@@ -1145,13 +1157,17 @@ async function resolveSiteDir(args) {
|
|
|
1145
1157
|
}
|
|
1146
1158
|
|
|
1147
1159
|
say.err('No site found in this workspace.')
|
|
1148
|
-
|
|
1160
|
+
if (verb === 'export') {
|
|
1161
|
+
say.dim('`export` produces a self-contained dist/ artifact for third-party hosting.')
|
|
1162
|
+
} else {
|
|
1163
|
+
say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
|
|
1164
|
+
}
|
|
1149
1165
|
process.exit(1)
|
|
1150
1166
|
}
|
|
1151
1167
|
|
|
1152
1168
|
async function fetchLatestRuntime(workerUrl) {
|
|
1153
1169
|
try {
|
|
1154
|
-
const res = await fetch(`${workerUrl}/
|
|
1170
|
+
const res = await fetch(`${workerUrl}/runtime/latest`)
|
|
1155
1171
|
if (!res.ok) return null
|
|
1156
1172
|
const body = await res.json()
|
|
1157
1173
|
return body.version || null
|
|
@@ -1307,6 +1323,50 @@ async function callPublish({ url, token, body }) {
|
|
|
1307
1323
|
return res.json()
|
|
1308
1324
|
}
|
|
1309
1325
|
|
|
1326
|
+
// ─── Site-bound foundation upload (Phase 4d) ────────────────
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
|
|
1330
|
+
* — the shape `POST /foundations` expects in its `files` field.
|
|
1331
|
+
*/
|
|
1332
|
+
async function collectFoundationDistFiles(distDir) {
|
|
1333
|
+
if (!existsSync(distDir)) {
|
|
1334
|
+
say.err(`Foundation dist/ not found at ${distDir}.`)
|
|
1335
|
+
process.exit(1)
|
|
1336
|
+
}
|
|
1337
|
+
const files = {}
|
|
1338
|
+
const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
|
|
1339
|
+
for (const entry of entries) {
|
|
1340
|
+
if (!entry.isFile()) continue
|
|
1341
|
+
const fullPath = join(entry.parentPath, entry.name)
|
|
1342
|
+
const relPath = relative(distDir, fullPath).split(sep).join('/')
|
|
1343
|
+
const bytes = await readFile(fullPath)
|
|
1344
|
+
files[relPath] = bytes.toString('base64')
|
|
1345
|
+
}
|
|
1346
|
+
return files
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
async function callFoundationUpload({ url, token, body }) {
|
|
1350
|
+
const res = await fetch(url, {
|
|
1351
|
+
method: 'POST',
|
|
1352
|
+
headers: {
|
|
1353
|
+
'Content-Type': 'application/json',
|
|
1354
|
+
Authorization: `Bearer ${token}`,
|
|
1355
|
+
},
|
|
1356
|
+
body: JSON.stringify(body),
|
|
1357
|
+
})
|
|
1358
|
+
if (!res.ok) {
|
|
1359
|
+
let err = `HTTP ${res.status}`
|
|
1360
|
+
try {
|
|
1361
|
+
const j = await res.json()
|
|
1362
|
+
err = j.error || err
|
|
1363
|
+
} catch {}
|
|
1364
|
+
say.err(`Foundation upload failed: ${err}`)
|
|
1365
|
+
process.exit(1)
|
|
1366
|
+
}
|
|
1367
|
+
return res.json()
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1310
1370
|
// ─── Asset pipeline (Phase 4) ──────────────────────────────
|
|
1311
1371
|
|
|
1312
1372
|
/**
|
|
@@ -1318,13 +1378,37 @@ async function callPublish({ url, token, body }) {
|
|
|
1318
1378
|
* siteContent is mutated in place so the caller's publish payload picks up
|
|
1319
1379
|
* the rewritten nodes without passing anything back.
|
|
1320
1380
|
*/
|
|
1321
|
-
async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
|
|
1381
|
+
async function uploadAssetsAndRewriteContent({ siteDir, localeContents, dataFileObjects = {}, siteYml, theme, backendUrl, cliToken, siteId }) {
|
|
1322
1382
|
const distAssetsDir = join(siteDir, 'dist', 'assets')
|
|
1323
1383
|
const hasDistAssets = existsSync(distAssetsDir)
|
|
1324
1384
|
|
|
1325
1385
|
// 1. Enumerate local files + read size.
|
|
1326
1386
|
const localFiles = hasDistAssets ? await walkAssetDir(distAssetsDir) : []
|
|
1327
1387
|
|
|
1388
|
+
// 1a. Content-scan: walk site-content.json (and locale variants) for any
|
|
1389
|
+
// asset references (image/document src/href) and resolve absolute
|
|
1390
|
+
// paths to local files under `dist/` or `public/`. This catches static
|
|
1391
|
+
// assets the author placed in `public/covers/`, `public/images/`, etc.
|
|
1392
|
+
// that the dist/assets walk above misses (vite's image-pipeline only
|
|
1393
|
+
// produces files for refs that go through it). Each resolved file
|
|
1394
|
+
// joins the upload pipeline; the rewrite step at the end maps every
|
|
1395
|
+
// such reference to its CDN identifier so content stays portable
|
|
1396
|
+
// across site delete / template extraction.
|
|
1397
|
+
const contentRefMap = await scanContentForAssetRefs(localeContents, dataFileObjects, siteDir)
|
|
1398
|
+
const seenPaths = new Set(localFiles.map((f) => f.fullPath))
|
|
1399
|
+
for (const [, info] of contentRefMap) {
|
|
1400
|
+
if (seenPaths.has(info.resolvedPath)) continue
|
|
1401
|
+
const ext = (info.filename.split('.').pop() || '').toLowerCase()
|
|
1402
|
+
const st = await stat(info.resolvedPath)
|
|
1403
|
+
localFiles.push({
|
|
1404
|
+
filename: info.filename,
|
|
1405
|
+
fullPath: info.resolvedPath,
|
|
1406
|
+
size: st.size,
|
|
1407
|
+
mime: MIME_BY_EXT[ext] || 'application/octet-stream',
|
|
1408
|
+
})
|
|
1409
|
+
seenPaths.add(info.resolvedPath)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1328
1412
|
// 1a. Favicon — sits at site root, not in dist/assets. Ship it through
|
|
1329
1413
|
// the same pipeline so it ends up at assets.uniweb.app with an
|
|
1330
1414
|
// identifier; config.favicon gets set further down.
|
|
@@ -1444,14 +1528,39 @@ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml,
|
|
|
1444
1528
|
}
|
|
1445
1529
|
|
|
1446
1530
|
// 6. Rewrite each locale's content in place. Image/document nodes whose
|
|
1447
|
-
// src/href references
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1531
|
+
// src/href references an uploaded asset get an info.identifier pointing
|
|
1532
|
+
// to the CDN. Walking every locale means translated content (which
|
|
1533
|
+
// still references the same image files via the source ProseMirror
|
|
1534
|
+
// tree) gets the same rewrite.
|
|
1535
|
+
//
|
|
1536
|
+
// Two lookup paths:
|
|
1537
|
+
// - byOriginalRef: full src/href string → identifier (covers static
|
|
1538
|
+
// public/ assets like `/covers/foo.svg` and dist/-resolved refs)
|
|
1539
|
+
// - byFilename: legacy match for `assets/{filename}` shape — kept
|
|
1540
|
+
// for back-compat with content authored against the old vite-
|
|
1541
|
+
// produced `/assets/...` URLs.
|
|
1451
1542
|
const byFilenameAll = new Map([...reused, ...fresh])
|
|
1543
|
+
const byOriginalRef = new Map()
|
|
1544
|
+
for (const [ref, info] of contentRefMap) {
|
|
1545
|
+
const id = byFilenameAll.get(info.filename)
|
|
1546
|
+
if (id) byOriginalRef.set(ref, id)
|
|
1547
|
+
}
|
|
1452
1548
|
let rewritten = 0
|
|
1453
1549
|
for (const lang of Object.keys(localeContents)) {
|
|
1454
|
-
rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll)
|
|
1550
|
+
rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll, byOriginalRef)
|
|
1551
|
+
}
|
|
1552
|
+
// Data files: walk the JSON tree. Two patterns coexist in collection
|
|
1553
|
+
// payloads:
|
|
1554
|
+
// - Flat fields (e.g. `article.image: "/covers/foo.svg"`) → replace
|
|
1555
|
+
// the string with a resolveAssetCdnUrl(identifier). The runtime
|
|
1556
|
+
// reads these as plain URLs, so rewriting at deploy time is the
|
|
1557
|
+
// simplest path to portability.
|
|
1558
|
+
// - Nested ProseMirror sub-trees (e.g. `article.content`) → use the
|
|
1559
|
+
// existing image/document node rewrite (sets `attrs.info.identifier`).
|
|
1560
|
+
for (const k of Object.keys(dataFileObjects)) {
|
|
1561
|
+
if (dataFileObjects[k] === null) continue
|
|
1562
|
+
rewritten += rewriteFlatAssetUrls(dataFileObjects[k], byOriginalRef)
|
|
1563
|
+
rewritten += rewriteAssetReferences(dataFileObjects[k], byFilenameAll, byOriginalRef)
|
|
1455
1564
|
}
|
|
1456
1565
|
if (rewritten > 0) {
|
|
1457
1566
|
say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
|
|
@@ -1725,44 +1834,68 @@ async function runInPool(items, concurrency, worker) {
|
|
|
1725
1834
|
|
|
1726
1835
|
/**
|
|
1727
1836
|
* Walk siteContent (ProseMirror-ish JSON tree) and rewrite any node whose
|
|
1728
|
-
* `attrs.src` or `attrs.href` references
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1837
|
+
* `attrs.src` or `attrs.href` references an uploaded/reused asset. Sets
|
|
1838
|
+
* `attrs.info.identifier` so semantic-parser resolves the real CDN URL
|
|
1839
|
+
* (and optimized variants) at render time.
|
|
1840
|
+
*
|
|
1841
|
+
* Two lookup paths, in order:
|
|
1842
|
+
* 1. `byOriginalRef` — full src/href string → identifier. Covers static
|
|
1843
|
+
* public/ assets (`/covers/foo.svg`, `/images/foo.png`) and any
|
|
1844
|
+
* content-scan-resolved file. Decouples assets from site lifecycle
|
|
1845
|
+
* (templates can extract content + identifier; assets stay on CDN).
|
|
1846
|
+
* 2. `byFilename` (legacy) — only fires when the path matches the old
|
|
1847
|
+
* `/assets/{filename}` shape. Kept so re-deploys of content authored
|
|
1848
|
+
* against pre-content-scan CLIs still work.
|
|
1731
1849
|
*
|
|
1732
1850
|
* Returns the number of rewrites performed — useful for reporting, and to
|
|
1733
1851
|
* detect "nothing matched" (likely a content-shape mismatch worth flagging).
|
|
1734
1852
|
*/
|
|
1735
|
-
function rewriteAssetReferences(node, byFilename) {
|
|
1853
|
+
function rewriteAssetReferences(node, byFilename, byOriginalRef = new Map()) {
|
|
1736
1854
|
let count = 0
|
|
1737
1855
|
const walk = (n) => {
|
|
1738
1856
|
if (!n || typeof n !== 'object') return
|
|
1739
1857
|
if (Array.isArray(n)) { for (const child of n) walk(child); return }
|
|
1740
1858
|
if (n.attrs && typeof n.attrs === 'object') {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1859
|
+
// Prefer full-ref lookup (covers static + dist refs uniformly);
|
|
1860
|
+
// fall back to legacy `assets/{filename}` extraction.
|
|
1861
|
+
let identifier = null
|
|
1862
|
+
let srcMatched = false
|
|
1863
|
+
let hrefMatched = false
|
|
1864
|
+
if (typeof n.attrs.src === 'string' && byOriginalRef.has(n.attrs.src)) {
|
|
1865
|
+
identifier = byOriginalRef.get(n.attrs.src)
|
|
1866
|
+
srcMatched = true
|
|
1867
|
+
} else if (typeof n.attrs.href === 'string' && byOriginalRef.has(n.attrs.href)) {
|
|
1868
|
+
identifier = byOriginalRef.get(n.attrs.href)
|
|
1869
|
+
hrefMatched = true
|
|
1870
|
+
} else {
|
|
1871
|
+
const srcRef = pickAssetRef(n.attrs.src)
|
|
1872
|
+
const hrefRef = pickAssetRef(n.attrs.href)
|
|
1873
|
+
const ref = srcRef || hrefRef
|
|
1874
|
+
if (ref) {
|
|
1875
|
+
identifier = byFilename.get(ref) || null
|
|
1876
|
+
srcMatched = !!srcRef
|
|
1877
|
+
hrefMatched = !srcRef && !!hrefRef
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (identifier) {
|
|
1881
|
+
n.attrs.info = {
|
|
1882
|
+
...(n.attrs.info || {}),
|
|
1883
|
+
identifier,
|
|
1884
|
+
contentType: 'website',
|
|
1885
|
+
viewType: 'profile',
|
|
1765
1886
|
}
|
|
1887
|
+
// Clear the local path so the runtime resolves via info.identifier
|
|
1888
|
+
// (→ assets.uniweb.app CDN) instead of requesting a non-existent
|
|
1889
|
+
// file from the site host.
|
|
1890
|
+
if (srcMatched) n.attrs.src = null
|
|
1891
|
+
if (hrefMatched) n.attrs.href = null
|
|
1892
|
+
// Match the Editor shape: plain `image` nodes skip identifier
|
|
1893
|
+
// resolution in older runtimes; `ImageBlock` routes through
|
|
1894
|
+
// parseImgBlock which reads info.identifier and fills url.
|
|
1895
|
+
if (n.type === 'image' && n.attrs.role !== 'icon') {
|
|
1896
|
+
n.type = 'ImageBlock'
|
|
1897
|
+
}
|
|
1898
|
+
count++
|
|
1766
1899
|
}
|
|
1767
1900
|
}
|
|
1768
1901
|
for (const v of Object.values(n)) if (typeof v === 'object') walk(v)
|
|
@@ -1778,6 +1911,153 @@ function pickAssetRef(v) {
|
|
|
1778
1911
|
return m ? m[1] : null
|
|
1779
1912
|
}
|
|
1780
1913
|
|
|
1914
|
+
/**
|
|
1915
|
+
* Walk every locale's content for `attrs.src` and `attrs.href` strings, and
|
|
1916
|
+
* resolve absolute-path refs (e.g. `/covers/foo.svg`) to local files under
|
|
1917
|
+
* the site root.
|
|
1918
|
+
*
|
|
1919
|
+
* Resolution order per ref:
|
|
1920
|
+
* 1. `dist/{path}` — vite outputs, link-mode collection JSON, etc.
|
|
1921
|
+
* 2. `public/{path}` — static author-placed assets (covers, images).
|
|
1922
|
+
*
|
|
1923
|
+
* Returns Map<originalRef, { resolvedPath, filename }> where:
|
|
1924
|
+
* - `originalRef` — the exact src/href string from content (used as the
|
|
1925
|
+
* lookup key during rewrite).
|
|
1926
|
+
* - `resolvedPath` — absolute path on disk (used for upload).
|
|
1927
|
+
* - `filename` — basename, used as the assets-server upload filename.
|
|
1928
|
+
* Server keys by (siteId, filename); collisions across
|
|
1929
|
+
* paths with the same basename are flagged as warnings.
|
|
1930
|
+
*
|
|
1931
|
+
* Skips:
|
|
1932
|
+
* - Non-string values, refs that don't start with `/`, protocol-relative
|
|
1933
|
+
* refs (`//cdn.example.com/...`), and external URLs.
|
|
1934
|
+
* - Refs starting with `/api/` or `/_` (worker-internal paths, never
|
|
1935
|
+
* local files).
|
|
1936
|
+
* - Nodes already rewritten with `attrs.info.identifier` set (re-deploy).
|
|
1937
|
+
*/
|
|
1938
|
+
async function scanContentForAssetRefs(localeContents, dataFileObjects, siteDir) {
|
|
1939
|
+
const candidates = new Set()
|
|
1940
|
+
for (const lang of Object.keys(localeContents)) {
|
|
1941
|
+
walkContentForAssetRefs(localeContents[lang], candidates)
|
|
1942
|
+
}
|
|
1943
|
+
// Also walk parsed collection JSON files. These contain BOTH ProseMirror-
|
|
1944
|
+
// shaped sub-trees (article.content) AND flat string fields (article.image,
|
|
1945
|
+
// article.cover, etc.). The walker captures both: any string-valued src/
|
|
1946
|
+
// href/image/cover/thumbnail/icon/poster field, plus any string anywhere
|
|
1947
|
+
// that looks like an absolute path with a known media extension.
|
|
1948
|
+
for (const k of Object.keys(dataFileObjects || {})) {
|
|
1949
|
+
if (dataFileObjects[k] !== null) {
|
|
1950
|
+
walkContentForAssetRefs(dataFileObjects[k], candidates)
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const results = new Map()
|
|
1955
|
+
const filenameToRef = new Map() // detect collisions (same basename, different path)
|
|
1956
|
+
for (const ref of candidates) {
|
|
1957
|
+
if (!isResolvableContentRef(ref)) continue
|
|
1958
|
+
const cleanPath = ref.split('?')[0].split('#')[0].slice(1) // drop leading '/'
|
|
1959
|
+
const distCandidate = join(siteDir, 'dist', cleanPath)
|
|
1960
|
+
const publicCandidate = join(siteDir, 'public', cleanPath)
|
|
1961
|
+
let resolvedPath = null
|
|
1962
|
+
if (existsSync(distCandidate)) {
|
|
1963
|
+
try { if ((await stat(distCandidate)).isFile()) resolvedPath = distCandidate } catch {}
|
|
1964
|
+
}
|
|
1965
|
+
if (!resolvedPath && existsSync(publicCandidate)) {
|
|
1966
|
+
try { if ((await stat(publicCandidate)).isFile()) resolvedPath = publicCandidate } catch {}
|
|
1967
|
+
}
|
|
1968
|
+
if (!resolvedPath) continue
|
|
1969
|
+
const filename = resolvedPath.split(sep).pop()
|
|
1970
|
+
const prior = filenameToRef.get(filename)
|
|
1971
|
+
if (prior && prior !== resolvedPath) {
|
|
1972
|
+
// Two different files want the same upload filename — server keys by
|
|
1973
|
+
// filename so the second would clobber the first. Skip + warn rather
|
|
1974
|
+
// than silently overwrite. Caller can rename the file or move one
|
|
1975
|
+
// into a vite-processed path to disambiguate via content hashing.
|
|
1976
|
+
say.warn(
|
|
1977
|
+
`Asset filename collision: "${filename}" exists at multiple paths ` +
|
|
1978
|
+
`(${prior}, ${resolvedPath}). Skipping the second; rename to disambiguate.`
|
|
1979
|
+
)
|
|
1980
|
+
continue
|
|
1981
|
+
}
|
|
1982
|
+
filenameToRef.set(filename, resolvedPath)
|
|
1983
|
+
results.set(ref, { resolvedPath, filename })
|
|
1984
|
+
}
|
|
1985
|
+
return results
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Field names commonly used for media in collection JSON. The walker
|
|
1989
|
+
// collects any absolute-path string under these keys as a potential asset
|
|
1990
|
+
// reference. ProseMirror image/link nodes are caught separately via attrs.
|
|
1991
|
+
const FLAT_ASSET_FIELDS = new Set([
|
|
1992
|
+
'src', 'href', 'image', 'cover', 'thumbnail', 'icon', 'poster', 'logo',
|
|
1993
|
+
'avatar', 'photo', 'banner', 'background',
|
|
1994
|
+
])
|
|
1995
|
+
|
|
1996
|
+
function walkContentForAssetRefs(node, refs) {
|
|
1997
|
+
if (!node || typeof node !== 'object') return
|
|
1998
|
+
if (Array.isArray(node)) { for (const child of node) walkContentForAssetRefs(child, refs); return }
|
|
1999
|
+
if (node.attrs && typeof node.attrs === 'object') {
|
|
2000
|
+
// Skip nodes already rewritten in a prior deploy — those have an
|
|
2001
|
+
// identifier and the runtime resolves them through the CDN already.
|
|
2002
|
+
if (!node.attrs.info?.identifier) {
|
|
2003
|
+
if (typeof node.attrs.src === 'string') refs.add(node.attrs.src)
|
|
2004
|
+
if (typeof node.attrs.href === 'string') refs.add(node.attrs.href)
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
// Flat fields: collection-shaped objects (e.g. an article record) often
|
|
2008
|
+
// carry media URLs as plain string fields rather than ProseMirror nodes.
|
|
2009
|
+
// Capture absolute-path values under known keys.
|
|
2010
|
+
for (const [k, v] of Object.entries(node)) {
|
|
2011
|
+
if (typeof v === 'string' && FLAT_ASSET_FIELDS.has(k) && isResolvableContentRef(v)) {
|
|
2012
|
+
refs.add(v)
|
|
2013
|
+
} else if (typeof v === 'object') {
|
|
2014
|
+
walkContentForAssetRefs(v, refs)
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Walk an arbitrary JSON tree and replace any string equal to a key in
|
|
2021
|
+
* `byOriginalRef` (and not already a CDN URL) with the asset's CDN URL.
|
|
2022
|
+
* Used for collection JSON files where image refs are flat string fields
|
|
2023
|
+
* (e.g. `article.image: "/covers/foo.svg"`) rather than ProseMirror nodes.
|
|
2024
|
+
*
|
|
2025
|
+
* Returns the number of replacements performed.
|
|
2026
|
+
*/
|
|
2027
|
+
function rewriteFlatAssetUrls(node, byOriginalRef) {
|
|
2028
|
+
let count = 0
|
|
2029
|
+
const walk = (n, parent, key) => {
|
|
2030
|
+
if (n == null) return
|
|
2031
|
+
if (typeof n === 'string') {
|
|
2032
|
+
const id = byOriginalRef.get(n)
|
|
2033
|
+
if (id && parent != null && key != null) {
|
|
2034
|
+
parent[key] = resolveAssetCdnUrl(id)
|
|
2035
|
+
count++
|
|
2036
|
+
}
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2039
|
+
if (typeof n !== 'object') return
|
|
2040
|
+
if (Array.isArray(n)) {
|
|
2041
|
+
for (let i = 0; i < n.length; i++) walk(n[i], n, i)
|
|
2042
|
+
return
|
|
2043
|
+
}
|
|
2044
|
+
for (const [k, v] of Object.entries(n)) walk(v, n, k)
|
|
2045
|
+
}
|
|
2046
|
+
walk(node, null, null)
|
|
2047
|
+
return count
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function isResolvableContentRef(ref) {
|
|
2051
|
+
if (typeof ref !== 'string' || !ref) return false
|
|
2052
|
+
// Absolute-path only — relative paths (`./foo`, `foo`) are content-author
|
|
2053
|
+
// shorthand handled elsewhere; URLs (`http://`, `//cdn`) never resolve to
|
|
2054
|
+
// local files; worker-internal paths (`/api/`, `/_`) aren't asset content.
|
|
2055
|
+
if (!ref.startsWith('/')) return false
|
|
2056
|
+
if (ref.startsWith('//')) return false
|
|
2057
|
+
if (ref.startsWith('/api/') || ref.startsWith('/_')) return false
|
|
2058
|
+
return true
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1781
2061
|
// ─── Loopback listener (review path) ───────────────────────
|
|
1782
2062
|
|
|
1783
2063
|
/**
|