uniweb 0.12.7 → 0.12.8
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 +35 -25
- package/src/commands/deploy.js +254 -353
- package/src/commands/export.js +85 -0
- package/src/commands/publish.js +288 -116
- package/src/framework-index.json +3 -3
- package/src/index.js +26 -8
- package/src/utils/env.js +22 -0
- package/src/utils/registry.js +4 -5
- package/src/utils/receipt.js +0 -91
package/src/commands/deploy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deploy Command
|
|
3
3
|
*
|
|
4
|
-
* Deploys a
|
|
5
|
-
* +
|
|
4
|
+
* Deploys a site to uniweb-edge. Always runtime-linked: the edge serves a
|
|
5
|
+
* runtime template + per-site base.html, with the foundation loaded by URL.
|
|
6
|
+
* For static-host artifacts (no upload), see `uniweb export`.
|
|
6
7
|
*
|
|
7
8
|
* Flow:
|
|
8
9
|
* 1. Read site.yml → { site.id?, site.handle?, foundation, runtime? }.
|
|
@@ -26,11 +27,15 @@
|
|
|
26
27
|
*
|
|
27
28
|
* Usage:
|
|
28
29
|
* uniweb deploy Normal deploy (browser may open on first deploy)
|
|
29
|
-
* uniweb deploy --skip-build Don't rebuild even if dist/ is stale
|
|
30
30
|
* uniweb deploy --dry-run Resolve everything but skip the Worker POST
|
|
31
|
-
* uniweb deploy --
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* uniweb deploy --no-auto-publish Don't auto-publish workspace-local foundation
|
|
32
|
+
*
|
|
33
|
+
* Internal escape hatches (UNIWEB_* env vars — see framework/cli/docs/env-vars.md):
|
|
34
|
+
* UNIWEB_SKIP_BUILD=1 Reuse existing dist/ instead of rebuilding
|
|
35
|
+
* UNIWEB_SKIP_ASSETS=1 Skip the asset upload step
|
|
36
|
+
* UNIWEB_SKIP_BILLING=1 Admin-only: bypass billing gate
|
|
37
|
+
* UNIWEB_FORCE_REVIEW=1 Force the browser review flow
|
|
38
|
+
* UNIWEB_ALLOW_DIRTY_FOUNDATION=1 Don't treat a dirty workspace as stale
|
|
34
39
|
*
|
|
35
40
|
* See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
|
|
36
41
|
*/
|
|
@@ -46,8 +51,20 @@ import { detectFoundationType } from '@uniweb/build'
|
|
|
46
51
|
|
|
47
52
|
import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
|
|
48
53
|
import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
|
|
54
|
+
import { parseBoolEnv } from '../utils/env.js'
|
|
49
55
|
import { RemoteRegistry } from '../utils/registry.js'
|
|
50
|
-
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
|
|
59
|
+
* Returns null on any shape we don't recognize. Inlined here after the
|
|
60
|
+
* receipt-cache utility module was removed in Phase 4b — the only
|
|
61
|
+
* remaining caller is the staleness check below.
|
|
62
|
+
*/
|
|
63
|
+
function splitRegistryRef(ref) {
|
|
64
|
+
if (typeof ref !== 'string') return null
|
|
65
|
+
const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
|
|
66
|
+
return m ? { name: m[1], version: m[2] } : null
|
|
67
|
+
}
|
|
51
68
|
import {
|
|
52
69
|
findWorkspaceRoot,
|
|
53
70
|
findSites,
|
|
@@ -215,46 +232,41 @@ function composeFoundationUrl(ref, registryBase) {
|
|
|
215
232
|
}
|
|
216
233
|
|
|
217
234
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* inspector returns `stale: true` and the caller's auto-publish path
|
|
227
|
-
* runs as before.
|
|
235
|
+
* Decide whether a workspace-local foundation is stale relative to the
|
|
236
|
+
* registry's record, by comparing per-directory git provenance against
|
|
237
|
+
* the registry entry's `publishedFromGitSha`. No local cache file —
|
|
238
|
+
* `dist/publish.json` was deleted in Phase 4b of the CLI ergonomics
|
|
239
|
+
* overhaul because every fresh clone / CI run / collaborator paid the
|
|
240
|
+
* registry round-trip anyway, and the local cache only added confusing
|
|
241
|
+
* "stale receipt" warnings when collaborators had different `dist/`
|
|
242
|
+
* state.
|
|
228
243
|
*
|
|
229
|
-
* Returns `{ stale, reason
|
|
230
|
-
*
|
|
244
|
+
* Returns `{ stale, reason }`. The caller decides whether to auto-publish
|
|
245
|
+
* (Phase 2 default) or fail (`--no-auto-publish`).
|
|
231
246
|
*/
|
|
232
|
-
async function
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
247
|
+
async function inspectFoundationStaleness(localPath, { dirtyAsStale, registry, ref }) {
|
|
248
|
+
const { gitSha, gitDirty } = readGitState(localPath)
|
|
249
|
+
if (!gitSha) {
|
|
250
|
+
return { stale: true, reason: 'foundation directory is not in a git repo or has no commits' }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const split = splitRegistryRef(ref)
|
|
254
|
+
if (!split) {
|
|
255
|
+
return { stale: true, reason: 'cannot derive registry ref from package.json' }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let existingEntry
|
|
236
259
|
try {
|
|
237
|
-
|
|
260
|
+
existingEntry = await registry.getVersionEntry(split.name, split.version)
|
|
238
261
|
} 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
|
-
}
|
|
262
|
+
return { stale: true, reason: 'registry lookup failed' }
|
|
250
263
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!gitSha) {
|
|
254
|
-
return { stale: true, reason: 'foundation directory is not in a git repo or has no commits', receipt }
|
|
264
|
+
if (!existingEntry) {
|
|
265
|
+
return { stale: true, reason: `${split.name}@${split.version} not yet published` }
|
|
255
266
|
}
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
|
|
268
|
+
if (existingEntry.publishedFromGitSha && existingEntry.publishedFromGitSha !== gitSha) {
|
|
269
|
+
// Recorded sha differs from the foundation's per-directory
|
|
258
270
|
// last-touched commit. Normally that's "real" staleness — somebody
|
|
259
271
|
// committed changes to src/ that haven't been republished.
|
|
260
272
|
//
|
|
@@ -268,67 +280,17 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry
|
|
|
268
280
|
// hasn't materially changed. Don't fire staleness on the sha
|
|
269
281
|
// alone in that case; let the dirty-tree check below do its job
|
|
270
282
|
// 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) {
|
|
283
|
+
if (!existingEntry.publishedFromGitDirty) {
|
|
277
284
|
return {
|
|
278
285
|
stale: true,
|
|
279
|
-
reason: `foundation has new commits since last publish (${
|
|
280
|
-
receipt,
|
|
286
|
+
reason: `foundation has new commits since last publish (${existingEntry.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
|
|
281
287
|
}
|
|
282
288
|
}
|
|
283
289
|
}
|
|
284
290
|
if (gitDirty && dirtyAsStale) {
|
|
285
|
-
return { stale: true, reason: 'foundation working tree is dirty'
|
|
291
|
+
return { stale: true, reason: 'foundation working tree is dirty' }
|
|
286
292
|
}
|
|
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
|
-
})
|
|
293
|
+
return { stale: false }
|
|
332
294
|
}
|
|
333
295
|
|
|
334
296
|
/**
|
|
@@ -365,11 +327,10 @@ async function refFromAuthAndPkg(localPath) {
|
|
|
365
327
|
*
|
|
366
328
|
* Resolution order:
|
|
367
329
|
* 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.
|
|
330
|
+
* 2. Empty-scope synthesis from `pkg.uniweb.id` + the user's auth claim
|
|
331
|
+
* (`~<memberUuid>/<id>@<version>`). Same canonical shape the server
|
|
332
|
+
* stores under for empty-scope publishes. Phase 4d will replace this
|
|
333
|
+
* with `~{siteId}/...` derived from authorize.
|
|
373
334
|
* 3. null — caller falls through to the helpful "set uniweb.namespace"
|
|
374
335
|
* error message.
|
|
375
336
|
*/
|
|
@@ -403,154 +364,37 @@ async function deriveLocalFoundationRef(localPath) {
|
|
|
403
364
|
return `@${namespace}/${bareName}@${version}`
|
|
404
365
|
}
|
|
405
366
|
|
|
406
|
-
// Empty-scope
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
return null
|
|
414
|
-
}
|
|
415
|
-
|
|
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_-]+)@([^/]+)\//
|
|
367
|
+
// Empty-scope fallback: synthesize `~<memberUuid>/<id>@<version>` from
|
|
368
|
+
// the user's auth + package.json::uniweb.id. Same canonical shape the
|
|
369
|
+
// server stores under for empty-scope publishes. After Phase 4d this
|
|
370
|
+
// path is replaced by `~{siteId}/...` derived from authorize.
|
|
371
|
+
const fromAuth = await refFromAuthAndPkg(localPath)
|
|
372
|
+
if (fromAuth) return fromAuth
|
|
422
373
|
|
|
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
374
|
return null
|
|
433
375
|
}
|
|
434
376
|
|
|
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
377
|
// ─── Main ───────────────────────────────────────────────────
|
|
527
378
|
|
|
528
379
|
export async function deploy(args = []) {
|
|
529
|
-
const skipBuild = args.includes('--skip-build')
|
|
530
380
|
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.
|
|
381
|
+
// When `foundation:` in site.yml points at a workspace-local file: ref,
|
|
382
|
+
// deploy auto-publishes the foundation when the registry has no record
|
|
383
|
+
// of the current source's git sha. This flag opts out.
|
|
542
384
|
const autoPublishFoundation = !args.includes('--no-auto-publish')
|
|
543
|
-
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
const
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
385
|
+
|
|
386
|
+
// Internal escape hatches — see framework/cli/docs/env-vars.md. These
|
|
387
|
+
// are not user-facing flags; they exist for the platform test team,
|
|
388
|
+
// CI scripts, and dev-loop unblockers. The bare `deploy` command should
|
|
389
|
+
// do the right thing for normal users without any of them set.
|
|
390
|
+
const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
|
|
391
|
+
const skipAssets = parseBoolEnv('UNIWEB_SKIP_ASSETS')
|
|
392
|
+
const skipBilling = parseBoolEnv('UNIWEB_SKIP_BILLING')
|
|
393
|
+
const forceReview = parseBoolEnv('UNIWEB_FORCE_REVIEW')
|
|
394
|
+
// Inverse of the (now-removed) --no-dirty-as-stale flag. When true, a
|
|
395
|
+
// dirty workspace will NOT be treated as stale (won't trigger auto-publish
|
|
396
|
+
// of the foundation). Default: dirty IS stale.
|
|
397
|
+
const treatDirtyAsStale = !parseBoolEnv('UNIWEB_ALLOW_DIRTY_FOUNDATION')
|
|
554
398
|
|
|
555
399
|
const siteDir = await resolveSiteDir(args)
|
|
556
400
|
const backendUrl = getBackendUrl()
|
|
@@ -583,29 +427,29 @@ export async function deploy(args = []) {
|
|
|
583
427
|
say.dim('Foundation policy: exact (pinned)')
|
|
584
428
|
}
|
|
585
429
|
|
|
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}`)
|
|
430
|
+
// --dry-run gate. Must come BEFORE auto-publish (which writes to the
|
|
431
|
+
// registry) and BEFORE the site build (which writes to dist/). Earlier
|
|
432
|
+
// versions of this command had the dry-run check after both, which
|
|
433
|
+
// violated the contract that --dry-run performs zero writes. Languages
|
|
434
|
+
// and the default locale are unavailable here (they live in
|
|
435
|
+
// dist/site-content.json, which a dry-run won't build); the trade-off
|
|
436
|
+
// is intentional. Run `uniweb build` directly if you need that detail.
|
|
437
|
+
if (dryRun) {
|
|
438
|
+
say.info('Dry run — would deploy:')
|
|
439
|
+
say.dim(`Site dir : ${siteDir}`)
|
|
440
|
+
say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
|
|
441
|
+
say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
|
|
442
|
+
say.dim(`Runtime : ${siteYml.runtime || '(latest, resolved at authorize)'}`)
|
|
443
|
+
say.dim(`Backend (PHP) : ${backendUrl}`)
|
|
444
|
+
say.dim(`Worker : ${workerUrl}`)
|
|
445
|
+
return
|
|
607
446
|
}
|
|
608
447
|
|
|
448
|
+
// `uniweb deploy` always runtime-links: the edge serves a runtime
|
|
449
|
+
// template + per-site base.html, with the foundation loaded by URL.
|
|
450
|
+
// The historical --link / --bundle flags are gone (Phase 2 of the CLI
|
|
451
|
+
// ergonomics overhaul). For static-host artifacts, see `uniweb export`.
|
|
452
|
+
|
|
609
453
|
// Phase 2: resolve workspace-local `file:` foundation refs.
|
|
610
454
|
//
|
|
611
455
|
// The object form of `foundation:` already requires a registry ref
|
|
@@ -616,62 +460,52 @@ export async function deploy(args = []) {
|
|
|
616
460
|
// build runs in runtime mode against the just-published artifact instead
|
|
617
461
|
// of bundling the local foundation source. site.yml on disk is never
|
|
618
462
|
// modified.
|
|
619
|
-
|
|
463
|
+
// Phase 4d: detect a workspace-local foundation. The actual upload happens
|
|
464
|
+
// AFTER authorize (which mints siteId), so the canonical site-bound ref
|
|
465
|
+
// `~{siteId}/{name}@{ver}` is known by the time we publish. For now we
|
|
466
|
+
// just record what we'll need at upload time and pass a `~self/...`
|
|
467
|
+
// placeholder to authorize — the server rewrites it.
|
|
468
|
+
let localFoundation = null
|
|
620
469
|
if (typeof foundation === 'string') {
|
|
621
470
|
const detected = detectFoundationType(foundation, siteDir)
|
|
622
471
|
if (detected.type === 'local') {
|
|
623
472
|
const localPath = detected.path
|
|
624
473
|
const relPath = relative(siteDir, localPath) || localPath
|
|
625
474
|
|
|
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.`)
|
|
475
|
+
let pkg
|
|
476
|
+
try {
|
|
477
|
+
pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
|
|
478
|
+
} catch {
|
|
479
|
+
say.err(`Could not read ${relPath}/package.json.`)
|
|
641
480
|
process.exit(1)
|
|
642
481
|
}
|
|
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('')
|
|
482
|
+
const foundationName = pkg.uniweb?.id || pkg.name?.replace(/^[@~][^/]+\//, '') || pkg.name
|
|
483
|
+
const foundationVersion = pkg.version
|
|
484
|
+
if (!foundationName || !foundationVersion) {
|
|
485
|
+
say.err(`Foundation at ${relPath} needs both a name and a version in package.json.`)
|
|
486
|
+
process.exit(1)
|
|
662
487
|
}
|
|
663
488
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
489
|
+
localFoundation = {
|
|
490
|
+
path: localPath,
|
|
491
|
+
relPath,
|
|
492
|
+
name: foundationName,
|
|
493
|
+
version: foundationVersion,
|
|
669
494
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
495
|
+
|
|
496
|
+
// Send `~self/{name}@{ver}` as a placeholder. The server will rewrite
|
|
497
|
+
// to `~{siteId}/{name}@{ver}` once siteId is minted. The CLI uses the
|
|
498
|
+
// returned canonical ref for both the upload and the publish payload.
|
|
499
|
+
foundation = `~self/${foundationName}@${foundationVersion}`
|
|
673
500
|
}
|
|
674
501
|
}
|
|
502
|
+
// Honor --no-auto-publish for local foundations: surface the gate before
|
|
503
|
+
// we do any work.
|
|
504
|
+
if (localFoundation && !autoPublishFoundation) {
|
|
505
|
+
say.err(`Local foundation at ${localFoundation.relPath} would be auto-published as part of deploy.`)
|
|
506
|
+
say.dim('Drop --no-auto-publish to let deploy publish it, or change site.yml to reference a registry-published foundation.')
|
|
507
|
+
process.exit(1)
|
|
508
|
+
}
|
|
675
509
|
|
|
676
510
|
// Runtime defaults to "latest" resolved at authorize time.
|
|
677
511
|
let runtimeVersion = siteYml.runtime
|
|
@@ -705,23 +539,21 @@ export async function deploy(args = []) {
|
|
|
705
539
|
// detectFoundationType recognizes `@ns/name@version` refs as
|
|
706
540
|
// link-mode URLs, which auto-enters runtime mode. Prerender also
|
|
707
541
|
// auto-skips for link-mode foundations (HTML is rendered on the
|
|
708
|
-
// serving edge, not here).
|
|
542
|
+
// serving edge, not here). Always --link: the edge serves a runtime
|
|
543
|
+
// template + per-site base.html, never a self-contained vite bundle.
|
|
709
544
|
//
|
|
710
545
|
// For workspace-local foundations (Phase 2 resolution above),
|
|
711
546
|
// UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
|
|
712
|
-
// registry ref instead of site.yml's literal value.
|
|
713
|
-
// the
|
|
714
|
-
//
|
|
715
|
-
// Link mode doesn't run vite at all, so the env var is harmless
|
|
716
|
-
// there but still passed through for consistency.
|
|
547
|
+
// registry ref instead of site.yml's literal value. Link mode doesn't
|
|
548
|
+
// run vite at all, so the env var is harmless but passed through for
|
|
549
|
+
// consistency with future work.
|
|
717
550
|
//
|
|
718
551
|
// Spawn the SAME CLI binary that's currently running rather than
|
|
719
552
|
// `npx uniweb build` — npx walks node_modules and would resolve to
|
|
720
553
|
// whatever version is installed there (which might be older than
|
|
721
554
|
// the deploy CLI and silently ignore --link). `process.argv[1]`
|
|
722
555
|
// pins the inner build to the outer's exact version.
|
|
723
|
-
|
|
724
|
-
execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
|
|
556
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
|
|
725
557
|
cwd: siteDir,
|
|
726
558
|
stdio: 'inherit',
|
|
727
559
|
env: foundationBuildOverride
|
|
@@ -730,7 +562,7 @@ export async function deploy(args = []) {
|
|
|
730
562
|
})
|
|
731
563
|
console.log('')
|
|
732
564
|
} else if (!existsSync(contentPath)) {
|
|
733
|
-
say.err('No build found and
|
|
565
|
+
say.err('No build found and UNIWEB_SKIP_BUILD set. Run `uniweb build` first.')
|
|
734
566
|
process.exit(1)
|
|
735
567
|
}
|
|
736
568
|
if (!existsSync(contentPath)) {
|
|
@@ -762,25 +594,13 @@ export async function deploy(args = []) {
|
|
|
762
594
|
}
|
|
763
595
|
}
|
|
764
596
|
|
|
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
597
|
// Spin up the loopback listener eagerly — we need its callback URL for the
|
|
779
598
|
// authorize request even on the fast path (PHP may always return
|
|
780
599
|
// needsReview=true on first deploy / billing drift in future phases).
|
|
781
600
|
const loopback = await startLoopback()
|
|
782
601
|
|
|
783
602
|
let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl, mintedFeatures
|
|
603
|
+
let foundationUploadUrl // Phase 4d: returned by authorize for site-bound foundation uploads
|
|
784
604
|
try {
|
|
785
605
|
say.info('Requesting deploy authorization…')
|
|
786
606
|
const authorizeBody = {
|
|
@@ -805,16 +625,9 @@ export async function deploy(args = []) {
|
|
|
805
625
|
// Always sent as an array; missing/empty `features:` in site.yml
|
|
806
626
|
// is normalized to `[]`, meaning "no paid features".
|
|
807
627
|
desiredFeatures,
|
|
808
|
-
// User-forced review (
|
|
628
|
+
// User-forced review (UNIWEB_FORCE_REVIEW=1). PHP refuses to
|
|
809
629
|
// fast-path even when nothing else has drifted.
|
|
810
630
|
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
631
|
}
|
|
819
632
|
let authRes
|
|
820
633
|
try {
|
|
@@ -829,27 +642,18 @@ export async function deploy(args = []) {
|
|
|
829
642
|
say.dim('Treating as a new site — the create flow will run in your browser.')
|
|
830
643
|
authorizeBody.siteId = ''
|
|
831
644
|
authRes = await callAuthorize({ backendUrl, cliToken, body: authorizeBody })
|
|
645
|
+
} else if (err.status === 403 && authorizeBody.siteId) {
|
|
646
|
+
// Collaborator ACL — the user has the repo (and thus site.id in
|
|
647
|
+
// site.yml) but isn't owner or editor on this site. The server's
|
|
648
|
+
// 403 message names the owner; surface it verbatim.
|
|
649
|
+
say.err(err.message)
|
|
650
|
+
process.exit(1)
|
|
832
651
|
} else {
|
|
833
652
|
say.err(`Authorize failed: ${err.message}`)
|
|
834
653
|
process.exit(1)
|
|
835
654
|
}
|
|
836
655
|
}
|
|
837
656
|
|
|
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
657
|
if (authRes.needsReview) {
|
|
854
658
|
const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
|
|
855
659
|
// openBrowser returns a hint about whether a GUI was available. On
|
|
@@ -898,7 +702,13 @@ export async function deploy(args = []) {
|
|
|
898
702
|
handleResolved = authRes.handle
|
|
899
703
|
publishUrl = authRes.publishUrl
|
|
900
704
|
validateUrl = authRes.validateUrl
|
|
705
|
+
foundationUploadUrl = authRes.foundationUploadUrl
|
|
901
706
|
mintedFeatures = Array.isArray(authRes.features) ? authRes.features : null
|
|
707
|
+
// Phase 4d: server returns the canonical foundation ref. For
|
|
708
|
+
// `~self/...` placeholders this is the rewritten `~{siteId}/...`
|
|
709
|
+
// form; catalog refs pass through. The CLI uses this for both the
|
|
710
|
+
// foundation upload (next step) and the publish payload below.
|
|
711
|
+
if (authRes.foundationRef) foundation = authRes.foundationRef
|
|
902
712
|
}
|
|
903
713
|
} finally {
|
|
904
714
|
loopback.close()
|
|
@@ -918,6 +728,45 @@ export async function deploy(args = []) {
|
|
|
918
728
|
say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
|
|
919
729
|
}
|
|
920
730
|
|
|
731
|
+
// Phase 4d: upload site-bound foundation files directly. Replaces the
|
|
732
|
+
// pre-Phase-4d `execSync('uniweb publish')` flow — we now know the
|
|
733
|
+
// canonical `~{siteId}/{name}@{ver}` ref from authorize, and the worker's
|
|
734
|
+
// /api/foundations endpoint accepts the publish token's siteId claim
|
|
735
|
+
// for this scope.
|
|
736
|
+
if (localFoundation) {
|
|
737
|
+
say.info(`Building foundation at ${localFoundation.relPath}…`)
|
|
738
|
+
console.log('')
|
|
739
|
+
try {
|
|
740
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build`, {
|
|
741
|
+
cwd: localFoundation.path,
|
|
742
|
+
stdio: 'inherit',
|
|
743
|
+
})
|
|
744
|
+
} catch {
|
|
745
|
+
say.err(`Foundation build at ${localFoundation.relPath} failed. See output above.`)
|
|
746
|
+
process.exit(1)
|
|
747
|
+
}
|
|
748
|
+
console.log('')
|
|
749
|
+
|
|
750
|
+
say.info(`Uploading foundation as ${foundation}…`)
|
|
751
|
+
const foundationFiles = await collectFoundationDistFiles(join(localFoundation.path, 'dist'))
|
|
752
|
+
const foundationPublishUrl = foundationUploadUrl || `${workerUrl}/api/foundations`
|
|
753
|
+
const { gitSha: fGitSha, gitDirty: fGitDirty } = readGitState(localFoundation.path)
|
|
754
|
+
await callFoundationUpload({
|
|
755
|
+
url: foundationPublishUrl,
|
|
756
|
+
token: publishToken,
|
|
757
|
+
body: {
|
|
758
|
+
name: foundation.replace(/@[^@]+$/, ''), // strip `@version` to get `~{siteId}/{name}`
|
|
759
|
+
version: localFoundation.version,
|
|
760
|
+
files: foundationFiles,
|
|
761
|
+
metadata: {
|
|
762
|
+
...(fGitSha ? { publishedFromGitSha: fGitSha } : {}),
|
|
763
|
+
...(typeof fGitDirty === 'boolean' ? { publishedFromGitDirty: fGitDirty } : {}),
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
})
|
|
767
|
+
say.ok(`Foundation uploaded.`)
|
|
768
|
+
}
|
|
769
|
+
|
|
921
770
|
// Pre-flight against the Worker. Surfaces "foundation not published" /
|
|
922
771
|
// "runtime not found" / namespace mismatch BEFORE we ship content.
|
|
923
772
|
say.info('Validating foundation + runtime…')
|
|
@@ -1115,7 +964,11 @@ async function writeSiteYmlUpdates(path, current, updates) {
|
|
|
1115
964
|
|
|
1116
965
|
// ─── Resolve site dir + runtime ────────────────────────────
|
|
1117
966
|
|
|
1118
|
-
|
|
967
|
+
// Exported so `uniweb export` (commands/export.js) can reuse the same
|
|
968
|
+
// site-discovery logic without duplicating it. `verb` is the command
|
|
969
|
+
// being run ("deploy" or "export"); it appears in the error messages
|
|
970
|
+
// so the user gets accurate guidance.
|
|
971
|
+
export async function resolveSiteDir(args, verb = 'deploy') {
|
|
1119
972
|
const cwd = process.cwd()
|
|
1120
973
|
const prefix = getCliPrefix()
|
|
1121
974
|
|
|
@@ -1128,16 +981,16 @@ async function resolveSiteDir(args) {
|
|
|
1128
981
|
if (sites.length === 1) return resolve(workspaceRoot, sites[0])
|
|
1129
982
|
if (sites.length > 1) {
|
|
1130
983
|
if (isNonInteractive(args)) {
|
|
1131
|
-
say.err(
|
|
984
|
+
say.err(`Multiple sites found. Specify which one to ${verb}.`)
|
|
1132
985
|
console.log('')
|
|
1133
986
|
for (const s of sites) {
|
|
1134
|
-
console.log(` ${c.cyan}cd ${s} && ${prefix}
|
|
987
|
+
console.log(` ${c.cyan}cd ${s} && ${prefix} ${verb}${c.reset}`)
|
|
1135
988
|
}
|
|
1136
989
|
process.exit(1)
|
|
1137
990
|
}
|
|
1138
991
|
const choice = await promptSelect('Which site?', sites)
|
|
1139
992
|
if (!choice) {
|
|
1140
|
-
console.log(
|
|
993
|
+
console.log(`\n${verb.charAt(0).toUpperCase() + verb.slice(1)} cancelled.`)
|
|
1141
994
|
process.exit(0)
|
|
1142
995
|
}
|
|
1143
996
|
return resolve(workspaceRoot, choice)
|
|
@@ -1145,7 +998,11 @@ async function resolveSiteDir(args) {
|
|
|
1145
998
|
}
|
|
1146
999
|
|
|
1147
1000
|
say.err('No site found in this workspace.')
|
|
1148
|
-
|
|
1001
|
+
if (verb === 'export') {
|
|
1002
|
+
say.dim('`export` produces a self-contained dist/ artifact for third-party hosting.')
|
|
1003
|
+
} else {
|
|
1004
|
+
say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
|
|
1005
|
+
}
|
|
1149
1006
|
process.exit(1)
|
|
1150
1007
|
}
|
|
1151
1008
|
|
|
@@ -1307,6 +1164,50 @@ async function callPublish({ url, token, body }) {
|
|
|
1307
1164
|
return res.json()
|
|
1308
1165
|
}
|
|
1309
1166
|
|
|
1167
|
+
// ─── Site-bound foundation upload (Phase 4d) ────────────────
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Walk a built foundation's `dist/` directory and return `{ relPath: base64Bytes }`
|
|
1171
|
+
* — the shape `POST /api/foundations` expects in its `files` field.
|
|
1172
|
+
*/
|
|
1173
|
+
async function collectFoundationDistFiles(distDir) {
|
|
1174
|
+
if (!existsSync(distDir)) {
|
|
1175
|
+
say.err(`Foundation dist/ not found at ${distDir}.`)
|
|
1176
|
+
process.exit(1)
|
|
1177
|
+
}
|
|
1178
|
+
const files = {}
|
|
1179
|
+
const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
|
|
1180
|
+
for (const entry of entries) {
|
|
1181
|
+
if (!entry.isFile()) continue
|
|
1182
|
+
const fullPath = join(entry.parentPath, entry.name)
|
|
1183
|
+
const relPath = relative(distDir, fullPath).split(sep).join('/')
|
|
1184
|
+
const bytes = await readFile(fullPath)
|
|
1185
|
+
files[relPath] = bytes.toString('base64')
|
|
1186
|
+
}
|
|
1187
|
+
return files
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function callFoundationUpload({ url, token, body }) {
|
|
1191
|
+
const res = await fetch(url, {
|
|
1192
|
+
method: 'POST',
|
|
1193
|
+
headers: {
|
|
1194
|
+
'Content-Type': 'application/json',
|
|
1195
|
+
Authorization: `Bearer ${token}`,
|
|
1196
|
+
},
|
|
1197
|
+
body: JSON.stringify(body),
|
|
1198
|
+
})
|
|
1199
|
+
if (!res.ok) {
|
|
1200
|
+
let err = `HTTP ${res.status}`
|
|
1201
|
+
try {
|
|
1202
|
+
const j = await res.json()
|
|
1203
|
+
err = j.error || err
|
|
1204
|
+
} catch {}
|
|
1205
|
+
say.err(`Foundation upload failed: ${err}`)
|
|
1206
|
+
process.exit(1)
|
|
1207
|
+
}
|
|
1208
|
+
return res.json()
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1310
1211
|
// ─── Asset pipeline (Phase 4) ──────────────────────────────
|
|
1311
1212
|
|
|
1312
1213
|
/**
|