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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Deploy Command
3
3
  *
4
- * Deploys a built site to Uniweb hosting. Phase 1 link-mode, content + theme
5
- * + locales only (no binary assets yet).
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 --skip-billing Admin-only: bypass billing gate (dev/testing)
32
- * uniweb deploy --review Force the browser review path even when
33
- * there's no drift (e.g., to change features)
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
- import { receiptFromRegistryEntry, splitRegistryRef } from '../utils/receipt.js'
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
- * Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
219
- * and decide whether it's stale relative to the current source tree.
220
- *
221
- * When the receipt file is missing and a `registry` is provided, attempt
222
- * to refill it from the registry's index entry for `<name>@<version>`.
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.
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, receipt, refilled? }`. The caller decides
230
- * whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
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 inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
233
- const receiptPath = join(localPath, 'dist', 'publish.json')
234
- let receipt = null
235
- let refilled = false
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
- receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
260
+ existingEntry = await registry.getVersionEntry(split.name, split.version)
238
261
  } catch {
239
- if (registry) {
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
- const { gitSha, gitDirty } = readGitState(localPath)
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
- if (receipt.publishedFromGitSha && receipt.publishedFromGitSha !== gitSha) {
257
- // Receipt's recorded sha differs from the foundation's per-directory
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 (${receipt.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
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', receipt }
291
+ return { stale: true, reason: 'foundation working tree is dirty' }
286
292
  }
287
- return { stale: false, receipt, refilled }
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. The receipt at `dist/publish.json`. After a successful publish, the
369
- * receipt's `url` carries the canonical server-rewritten name —
370
- * including empty-scope publishes, which the server rewrites to
371
- * `~<memberUuid>/<name>`. The CLI can't synthesize that locally
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 path read the canonical name from the receipt's URL.
407
- // The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
408
- // returns carries that canonical form (e.g.
409
- // `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
410
- const fromReceipt = await refFromReceiptUrl(localPath)
411
- if (fromReceipt) return fromReceipt
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
- const skipAssets = args.includes('--skip-assets')
532
- const skipBilling = args.includes('--skip-billing')
533
- // --review forces the browser review path even when there's no drift.
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
- const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
544
- // Site mode`--link` ships data only (Uniweb-edge hosting); `--bundle`
545
- // ships a vite-built static-host artifact. Mutually exclusive. Resolution
546
- // ladder runs further below: explicit flag registry-ref / URL infer →
547
- // published-local-foundation infer ask-or-error.
548
- const linkFlag = args.includes('--link')
549
- const bundleFlag = args.includes('--bundle')
550
- if (linkFlag && bundleFlag) {
551
- say.err('Cannot pass both --link and --bundle.')
552
- process.exit(1)
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
- // Resolve deploy mode (link vs bundle) BEFORE the foundation
587
- // staleness check, because mode selection feeds into how we run
588
- // the site build later. The resolver may prompt the user on first
589
- // deploy; subsequent deploys infer or use the explicit flag.
590
- // TODO: when PHP authorize starts returning a persisted mode for
591
- // this site, reconcile it with the resolved mode here and reject
592
- // any mismatch ("delete site and redeploy fresh to change modes").
593
- const { mode: deployMode, source: modeSource } = await resolveDeployMode({
594
- foundationRef: foundation,
595
- siteDir,
596
- linkFlag,
597
- bundleFlag,
598
- })
599
- if (modeSource === 'inferred-registry-ref') {
600
- say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
601
- } else if (modeSource === 'inferred-published-local') {
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
- let foundationBuildOverride = null
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
- // Pass a RemoteRegistry into the inspector so it can refill a missing
627
- // `dist/publish.json` from the registry's index (fresh-clone case).
628
- // No auth needed `getVersionEntry` reads the public listing.
629
- const refillRegistry = new RemoteRegistry(workerUrl)
630
- const inspection = await inspectLocalFoundationReceipt(localPath, {
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
- if (inspection.stale) {
644
- say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
645
- console.log('')
646
- try {
647
- // Spawn the SAME CLI binary that's currently running, not via
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
- const resolved = await deriveLocalFoundationRef(localPath)
665
- if (!resolved) {
666
- say.err(`Could not derive a registry ref for foundation at ${relPath}.`)
667
- say.dim(`Make sure its package.json has a name + version and a namespace (uniweb.namespace, scoped name, or run \`uniweb publish --namespace <handle>\` once).`)
668
- process.exit(1)
489
+ localFoundation = {
490
+ path: localPath,
491
+ relPath,
492
+ name: foundationName,
493
+ version: foundationVersion,
669
494
  }
670
- say.dim(`Foundation: ${foundation} → ${resolved} (resolved from ${relPath})`)
671
- foundation = resolved
672
- foundationBuildOverride = resolved
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. In bundle mode
713
- // the build produces a runtime-mode bundle pointing at the just-
714
- // published foundation rather than embedding the local source.
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
- const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
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 --skip-build passed. Run `uniweb build` first.')
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 (`uniweb deploy --review`). PHP refuses to
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
- async function resolveSiteDir(args) {
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('Multiple sites found. Specify which one to deploy.')
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} deploy${c.reset}`)
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('\nDeploy cancelled.')
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
- say.dim('`deploy` publishes a built Uniweb site to the hosting platform.')
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
  /**